Skip to main content

cu29_runtime/
curuntime.rs

1//! CuRuntime is the heart of what copper is running on the robot.
2//! It is exposed to the user via the `copper_runtime` macro injecting it as a field in their application struct.
3//!
4
5use crate::app::Subsystem;
6#[cfg(feature = "std")]
7use crate::arena::{CuSlotLease, allocate_slots};
8use crate::config::{ComponentConfig, DEFAULT_KEYFRAME_INTERVAL, Node, TaskKind};
9use crate::config::{
10    CuConfig, CuGraph, MAX_RATE_TARGET_HZ, NodeId, RuntimeConfig, resolve_task_kind_for_id,
11};
12use crate::copperlist::{CopperList, CopperListState, CuListZeroedInit, CuListsManager};
13use crate::cutask::{BincodeAdapter, Freezable};
14#[cfg(feature = "std")]
15use crate::monitoring::ExecutionProbeHandle;
16#[cfg(feature = "std")]
17use crate::monitoring::MonitorExecutionProbe;
18use crate::monitoring::{
19    ComponentId, CopperListInfo, CuMonitor, CuMonitoringMetadata, CuMonitoringRuntime,
20    ExecutionMarker, MonitorComponentMetadata, RuntimeExecutionProbe, build_monitor_topology,
21    take_last_completed_handle_bytes,
22};
23#[cfg(all(feature = "std", feature = "parallel-rt"))]
24use crate::parallel_rt::ParallelRt;
25#[cfg(feature = "std")]
26use crate::parallel_rt::ParallelRtMetadata;
27use crate::planner::{check_order, plan_from_order, topo_bfs_order};
28use crate::resource::ResourceManager;
29#[cfg(feature = "std")]
30use alloc::sync::Arc;
31use compact_str::CompactString;
32use cu29_clock::{ClockProvider, CuDuration, CuTime, RobotClock};
33use cu29_traits::CuResult;
34use cu29_traits::WriteStream;
35use cu29_traits::{CopperListTuple, CuError};
36#[cfg(feature = "std")]
37use rayon::ThreadPool;
38
39#[cfg(target_os = "none")]
40#[allow(unused_imports)]
41use cu29_log::{ANONYMOUS, CuLogEntry, CuLogLevel};
42#[cfg(target_os = "none")]
43#[allow(unused_imports)]
44use cu29_log_derive::info;
45#[cfg(target_os = "none")]
46#[allow(unused_imports)]
47use cu29_log_runtime::log;
48#[cfg(all(target_os = "none", debug_assertions))]
49#[allow(unused_imports)]
50use cu29_log_runtime::log_debug_mode;
51#[cfg(target_os = "none")]
52#[allow(unused_imports)]
53use cu29_value::to_value;
54
55use alloc::boxed::Box;
56use alloc::format;
57use alloc::string::{String, ToString};
58use alloc::vec::Vec;
59use bincode::de::read::Reader;
60use bincode::de::{Decoder, DecoderImpl};
61use bincode::enc::EncoderImpl;
62use bincode::enc::write::{SizeWriter, Writer};
63use bincode::error::{DecodeError, EncodeError};
64use bincode::{Decode, Encode};
65use core::fmt::Result as FmtResult;
66use core::fmt::{Debug, Formatter};
67use core::marker::PhantomData;
68
69#[cfg(all(feature = "std", feature = "async-cl-io"))]
70use rtrb::{Consumer, PopError, Producer, PushError, RingBuffer};
71#[cfg(all(feature = "std", feature = "async-cl-io"))]
72use std::sync::atomic::{AtomicBool, Ordering};
73#[cfg(all(feature = "std", feature = "async-cl-io"))]
74use std::thread::{JoinHandle, Thread};
75
76#[cfg(feature = "std")]
77#[doc(hidden)]
78pub type TasksInstantiator<CT> = for<'c> fn(
79    Vec<Option<&'c ComponentConfig>>,
80    &mut ResourceManager,
81    &[Option<Arc<ThreadPool>>],
82) -> CuResult<CT>;
83#[cfg(not(feature = "std"))]
84#[doc(hidden)]
85pub type TasksInstantiator<CT> =
86    for<'c> fn(Vec<Option<&'c ComponentConfig>>, &mut ResourceManager) -> CuResult<CT>;
87#[doc(hidden)]
88pub type BridgesInstantiator<CB> = fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>;
89/// Instantiates the rayon thread pools described by `runtime.thread_pools`.
90///
91/// Returned vector is indexed positionally to `runtime.thread_pools`; reserved
92/// pool ids (such as [`crate::config::RT_POOL`]) leave a `None` slot since they
93/// are applied directly to runtime-owned worker threads rather than borrowed as
94/// a rayon pool.
95#[cfg(feature = "std")]
96#[doc(hidden)]
97pub type ThreadPoolsInstantiator = fn(&CuConfig) -> CuResult<Vec<Option<Arc<ThreadPool>>>>;
98#[doc(hidden)]
99pub type MonitorInstantiator<M> = fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M;
100
101#[doc(hidden)]
102pub struct CuRuntimeParts<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize, TI, BI, MI> {
103    pub tasks_instanciator: TI,
104    pub monitored_components: &'static [MonitorComponentMetadata],
105    pub culist_component_mapping: &'static [ComponentId],
106    #[cfg(feature = "std")]
107    pub parallel_rt_metadata: &'static ParallelRtMetadata,
108    pub monitor_instanciator: MI,
109    pub bridges_instanciator: BI,
110    _payload: PhantomData<(CT, CB, P, M, [(); NBCL])>,
111}
112
113impl<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize, TI, BI, MI>
114    CuRuntimeParts<CT, CB, P, M, NBCL, TI, BI, MI>
115{
116    pub const fn new(
117        tasks_instanciator: TI,
118        monitored_components: &'static [MonitorComponentMetadata],
119        culist_component_mapping: &'static [ComponentId],
120        #[cfg(feature = "std")] parallel_rt_metadata: &'static ParallelRtMetadata,
121        monitor_instanciator: MI,
122        bridges_instanciator: BI,
123    ) -> Self {
124        Self {
125            tasks_instanciator,
126            monitored_components,
127            culist_component_mapping,
128            #[cfg(feature = "std")]
129            parallel_rt_metadata,
130            monitor_instanciator,
131            bridges_instanciator,
132            _payload: PhantomData,
133        }
134    }
135}
136
137#[doc(hidden)]
138pub struct CuRuntimeBuilder<
139    'cfg,
140    CT,
141    CB,
142    P: CopperListTuple,
143    M: CuMonitor,
144    const NBCL: usize,
145    TI,
146    BI,
147    MI,
148    CLS,
149    KFS,
150> {
151    clock: RobotClock,
152    config: &'cfg CuConfig,
153    mission: &'cfg str,
154    subsystem: Subsystem,
155    instance_id: u32,
156    resources: Option<ResourceManager>,
157    #[cfg(feature = "std")]
158    thread_pools: Option<Vec<Option<Arc<ThreadPool>>>>,
159    parts: CuRuntimeParts<CT, CB, P, M, NBCL, TI, BI, MI>,
160    copperlist_sink: CLS,
161    keyframe_sink: KFS,
162    output_requirements: OutputRequirements,
163}
164
165impl<'cfg, CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize, TI, BI, MI, CLS, KFS>
166    CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLS, KFS>
167{
168    pub fn new(
169        clock: RobotClock,
170        config: &'cfg CuConfig,
171        mission: &'cfg str,
172        parts: CuRuntimeParts<CT, CB, P, M, NBCL, TI, BI, MI>,
173        copperlist_sink: CLS,
174        keyframe_sink: KFS,
175        output_requirements: OutputRequirements,
176    ) -> Self {
177        Self {
178            clock,
179            config,
180            mission,
181            subsystem: Subsystem::new(None, 0),
182            instance_id: 0,
183            resources: None,
184            #[cfg(feature = "std")]
185            thread_pools: None,
186            parts,
187            copperlist_sink,
188            keyframe_sink,
189            output_requirements,
190        }
191    }
192
193    pub fn with_subsystem(mut self, subsystem: Subsystem) -> Self {
194        self.subsystem = subsystem;
195        self
196    }
197
198    pub fn with_instance_id(mut self, instance_id: u32) -> Self {
199        self.instance_id = instance_id;
200        self
201    }
202
203    pub fn with_resources(mut self, resources: ResourceManager) -> Self {
204        self.resources = Some(resources);
205        self
206    }
207
208    pub fn try_with_resources_instantiator(
209        mut self,
210        resources_instantiator: impl FnOnce(&CuConfig) -> CuResult<ResourceManager>,
211    ) -> CuResult<Self> {
212        self.resources = Some(resources_instantiator(self.config)?);
213        Ok(self)
214    }
215
216    /// Provides pre-built thread pools; positions in the slice must match
217    /// `runtime.thread_pools` indices. Reserved pool ids (e.g. `"rt"`) belong
218    /// to runtime-owned worker threads and stay as `None` slots.
219    #[cfg(feature = "std")]
220    pub fn with_thread_pools(mut self, pools: Vec<Option<Arc<ThreadPool>>>) -> Self {
221        self.thread_pools = Some(pools);
222        self
223    }
224
225    #[cfg(feature = "std")]
226    pub fn try_with_thread_pools_instantiator(
227        mut self,
228        thread_pools_instantiator: impl FnOnce(&CuConfig) -> CuResult<Vec<Option<Arc<ThreadPool>>>>,
229    ) -> CuResult<Self> {
230        self.thread_pools = Some(thread_pools_instantiator(self.config)?);
231        Ok(self)
232    }
233}
234
235/// Returns a monotonic instant used for local runtime performance timing.
236///
237/// When `sysclock-perf` (and `std`) are enabled this uses a process-local
238/// `RobotClock::new()` instance for timing. The returned value is a
239/// monotonically increasing duration since an unspecified origin (typically
240/// process or runtime initialization), not a wall-clock time-of-day. When
241/// `sysclock-perf` is disabled it delegates to the provided `RobotClock`.
242///
243/// This is intentionally separate from `LoopRateLimiter`, which always uses the
244/// provided `RobotClock` so `runtime.rate_target_hz` stays tied to robot time.
245#[inline]
246pub fn perf_now(_clock: &RobotClock) -> CuTime {
247    #[cfg(all(feature = "std", feature = "sysclock-perf"))]
248    {
249        static PERF_CLOCK: std::sync::OnceLock<RobotClock> = std::sync::OnceLock::new();
250        return PERF_CLOCK.get_or_init(RobotClock::new).now();
251    }
252
253    #[allow(unreachable_code)]
254    _clock.now()
255}
256
257#[cfg(all(feature = "std", feature = "high-precision-limiter"))]
258const HIGH_PRECISION_LIMITER_SPIN_WINDOW_NS: u64 = 200_000;
259
260/// Convert a configured runtime rate target to an integer-nanosecond period.
261#[inline]
262pub fn rate_target_period(rate_target_hz: u64) -> CuResult<CuDuration> {
263    if rate_target_hz == 0 {
264        return Err(CuError::from(
265            "Runtime rate target cannot be zero. Set runtime.rate_target_hz to at least 1.",
266        ));
267    }
268
269    if rate_target_hz > MAX_RATE_TARGET_HZ {
270        return Err(CuError::from(format!(
271            "Runtime rate target ({rate_target_hz} Hz) exceeds the supported maximum of {MAX_RATE_TARGET_HZ} Hz."
272        )));
273    }
274
275    Ok(CuDuration::from(MAX_RATE_TARGET_HZ / rate_target_hz))
276}
277
278/// Runtime loop limiter that preserves phase with absolute deadlines.
279///
280/// This is intentionally a small runtime helper so generated applications do
281/// not have to open-code loop scheduling policy. Deadlines are tracked against
282/// the provided `RobotClock`, even when `sysclock-perf` is enabled for
283/// process-time measurements.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub struct LoopRateLimiter {
286    period: CuDuration,
287    next_deadline: CuTime,
288}
289
290impl LoopRateLimiter {
291    #[inline]
292    pub fn from_rate_target_hz(rate_target_hz: u64, clock: &RobotClock) -> CuResult<Self> {
293        let period = rate_target_period(rate_target_hz)?;
294        Ok(Self {
295            period,
296            next_deadline: clock.now() + period,
297        })
298    }
299
300    #[inline]
301    pub fn is_ready(&self, clock: &RobotClock) -> bool {
302        self.remaining(clock).is_none()
303    }
304
305    #[inline]
306    pub fn remaining(&self, clock: &RobotClock) -> Option<CuDuration> {
307        let now = clock.now();
308        if now < self.next_deadline {
309            Some(self.next_deadline - now)
310        } else {
311            None
312        }
313    }
314
315    #[inline]
316    pub fn wait_until_ready(&self, clock: &RobotClock) {
317        let deadline = self.next_deadline;
318        let Some(remaining) = self.remaining(clock) else {
319            return;
320        };
321
322        #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
323        {
324            let spin_window = self.spin_window();
325            if remaining > spin_window {
326                std::thread::sleep(std::time::Duration::from(remaining - spin_window));
327            }
328            while clock.now() < deadline {
329                core::hint::spin_loop();
330            }
331        }
332
333        #[cfg(all(feature = "std", not(feature = "high-precision-limiter")))]
334        {
335            let _ = deadline;
336            std::thread::sleep(std::time::Duration::from(remaining));
337        }
338
339        #[cfg(not(feature = "std"))]
340        {
341            let _ = remaining;
342            while clock.now() < deadline {
343                core::hint::spin_loop();
344            }
345        }
346    }
347
348    #[inline]
349    pub fn mark_tick(&mut self, clock: &RobotClock) {
350        self.advance_from(clock.now());
351    }
352
353    #[inline]
354    pub fn limit(&mut self, clock: &RobotClock) {
355        self.wait_until_ready(clock);
356        self.mark_tick(clock);
357    }
358
359    #[inline]
360    fn advance_from(&mut self, now: CuTime) {
361        let steps = if now < self.next_deadline {
362            1
363        } else {
364            (now - self.next_deadline).as_nanos() / self.period.as_nanos() + 1
365        };
366        self.next_deadline += steps * self.period;
367    }
368
369    #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
370    #[inline]
371    fn spin_window(&self) -> CuDuration {
372        let _ = self.period;
373        CuDuration::from(HIGH_PRECISION_LIMITER_SPIN_WINDOW_NS)
374    }
375
376    #[cfg(test)]
377    #[inline]
378    fn next_deadline(&self) -> CuTime {
379        self.next_deadline
380    }
381}
382
383#[cfg(all(feature = "std", feature = "async-cl-io"))]
384#[doc(hidden)]
385pub trait AsyncCopperListPayload: Send {}
386
387#[cfg(all(feature = "std", feature = "async-cl-io"))]
388impl<T: Send> AsyncCopperListPayload for T {}
389
390#[cfg(not(all(feature = "std", feature = "async-cl-io")))]
391#[doc(hidden)]
392pub trait AsyncCopperListPayload {}
393
394#[cfg(not(all(feature = "std", feature = "async-cl-io")))]
395impl<T> AsyncCopperListPayload for T {}
396
397/// Control-flow result returned by one generated process stage.
398///
399/// `AbortCopperList` preserves the current runtime semantics for monitor
400/// decisions that abort the current CopperList without shutting the runtime
401/// down. The outer driver remains responsible for ordered cleanup and log
402/// handoff.
403#[derive(Clone, Copy, Debug, PartialEq, Eq)]
404#[doc(hidden)]
405pub enum ProcessStepOutcome {
406    Continue,
407    AbortCopperList,
408}
409
410/// Result type used by generated process-step functions.
411#[doc(hidden)]
412pub type ProcessStepResult = CuResult<ProcessStepOutcome>;
413
414#[cfg(feature = "remote-debug")]
415fn encode_completed_copperlist_snapshot<P: CopperListTuple>(
416    cl: &CopperList<P>,
417) -> CuResult<Vec<u8>> {
418    bincode::encode_to_vec(cl, bincode::config::standard())
419        .map_err(|e| CuError::new_with_cause("Failed to encode completed CopperList snapshot", e))
420}
421
422/// Existing type-erased leaf boundary for a semantic record consumer.
423///
424/// This deliberately aliases [`WriteStream`] rather than wrapping it. Generated
425/// code can statically compose concrete consumers behind this existing boundary
426/// without changing its object size or adding another virtual call.
427#[doc(hidden)]
428pub type SemanticRecordSink<T> = dyn WriteStream<T>;
429
430/// Semantic output boundary for a completed CopperList.
431#[doc(hidden)]
432pub type CompletedCopperListSink<P> = SemanticRecordSink<CopperList<P>>;
433
434/// Semantic output boundary for a completed keyframe.
435#[doc(hidden)]
436pub type CompletedKeyFrameSink = SemanticRecordSink<KeyFrame>;
437
438/// Zero-cost placeholder emitted when a semantic record family has no consumer.
439#[derive(Clone, Copy, Debug, Default)]
440#[doc(hidden)]
441pub struct NullWriteStream;
442
443impl<E: Encode> WriteStream<E> for NullWriteStream {
444    #[inline]
445    fn log(&mut self, _record: &E) -> CuResult<()> {
446        Ok(())
447    }
448}
449
450/// Semantic record families requested by the statically generated downstream graph.
451///
452/// This is deliberately independent from [`crate::config::LoggingConfig`]. Local
453/// unified logging is one possible downstream consumer; generated streaming sinks
454/// may request the same records even when local CopperList logging is disabled.
455#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
456#[doc(hidden)]
457pub struct OutputRequirements {
458    completed_copperlists: bool,
459    keyframes: bool,
460}
461
462impl OutputRequirements {
463    #[inline]
464    pub const fn new(completed_copperlists: bool, keyframes: bool) -> Self {
465        Self {
466            completed_copperlists,
467            keyframes,
468        }
469    }
470
471    #[inline]
472    pub const fn completed_copperlists(self) -> bool {
473        self.completed_copperlists
474    }
475
476    #[inline]
477    pub const fn keyframes(self) -> bool {
478        self.keyframes
479    }
480
481    /// Combines the record needs of independently generated downstream consumers.
482    #[inline]
483    pub const fn union(self, other: Self) -> Self {
484        Self::new(
485            self.completed_copperlists || other.completed_copperlists,
486            self.keyframes || other.keyframes,
487        )
488    }
489}
490
491/// Manages the lifecycle and completed-list sink on the synchronous path.
492#[doc(hidden)]
493pub struct SyncCopperListsManager<P: CopperListTuple + Default, const NBCL: usize> {
494    inner: Option<CuListsManager<P, NBCL>>,
495    #[cfg(feature = "std")]
496    free_pool: Vec<CuSlotLease<P>>,
497    #[cfg(feature = "std")]
498    keyframe_sink: Option<Box<CompletedKeyFrameSink>>,
499    #[cfg(feature = "std")]
500    next_distributed_id: u64,
501    #[cfg(feature = "std")]
502    pub last_keyframe_bytes: u64,
503    sink: Option<Box<CompletedCopperListSink<P>>>,
504    /// Remote-debug snapshot of the most recently completed CopperList.
505    #[cfg(feature = "remote-debug")]
506    last_completed_encoded: Option<Vec<u8>>,
507    /// Last local-log encoded size reported by the sink.
508    pub last_encoded_bytes: u64,
509    /// Last handle-backed payload bytes observed while the sink ran.
510    pub last_handle_bytes: u64,
511}
512
513impl<P: CopperListTuple + Default, const NBCL: usize> SyncCopperListsManager<P, NBCL> {
514    #[doc(hidden)]
515    pub fn close_output(&mut self) -> CuResult<()> {
516        self.finish_pending()?;
517        if let Some(sink) = &mut self.sink {
518            sink.flush()?;
519        }
520        self.sink.take();
521        #[cfg(feature = "std")]
522        {
523            if let Some(sink) = &mut self.keyframe_sink {
524                sink.flush()?;
525            }
526            self.keyframe_sink.take();
527        }
528        Ok(())
529    }
530
531    pub fn new(sink: Option<Box<CompletedCopperListSink<P>>>) -> CuResult<Self>
532    where
533        P: CuListZeroedInit,
534    {
535        Self::new_with_keyframes(sink, None)
536    }
537
538    pub fn new_with_keyframes(
539        sink: Option<Box<CompletedCopperListSink<P>>>,
540        keyframe_sink: Option<Box<CompletedKeyFrameSink>>,
541    ) -> CuResult<Self>
542    where
543        P: CuListZeroedInit,
544    {
545        #[cfg(not(feature = "std"))]
546        let _ = keyframe_sink;
547        Ok(Self {
548            inner: (!P::DISTRIBUTED).then(CuListsManager::new),
549            #[cfg(feature = "std")]
550            free_pool: if P::DISTRIBUTED {
551                allocate_slots::<P>(NBCL)
552            } else {
553                Vec::new()
554            },
555            #[cfg(feature = "std")]
556            keyframe_sink,
557            #[cfg(feature = "std")]
558            next_distributed_id: 0,
559            #[cfg(feature = "std")]
560            last_keyframe_bytes: 0,
561            sink,
562            #[cfg(feature = "remote-debug")]
563            last_completed_encoded: None,
564            last_encoded_bytes: 0,
565            last_handle_bytes: 0,
566        })
567    }
568
569    pub fn next_cl_id(&self) -> u64 {
570        #[cfg(feature = "std")]
571        {
572            self.inner
573                .as_ref()
574                .map_or(self.next_distributed_id, CuListsManager::next_cl_id)
575        }
576        #[cfg(not(feature = "std"))]
577        {
578            self.inner.as_ref().expect("serial storage").next_cl_id()
579        }
580    }
581
582    #[cfg(feature = "std")]
583    pub fn note_admission(&mut self, next_id: u64) {
584        self.next_distributed_id = self.next_distributed_id.max(next_id);
585    }
586
587    /// Aligns an idle allocator to a continuity-validated recorded boundary.
588    /// Generated replay calls this off the real-time path.
589    pub fn prepare_recorded_replay(&mut self, next_id: u64) -> CuResult<()> {
590        if !self.inner.as_ref().is_none_or(CuListsManager::is_empty) {
591            return Err(CuError::from(
592                "Cannot reposition replay with active CopperLists",
593            ));
594        }
595        if let Some(inner) = &mut self.inner {
596            inner.set_next_replay_id(next_id);
597        }
598        #[cfg(feature = "std")]
599        {
600            self.next_distributed_id = next_id;
601        }
602        Ok(())
603    }
604
605    pub fn last_cl_id(&self) -> u64 {
606        self.next_cl_id().saturating_sub(1)
607    }
608
609    pub fn peek(&self) -> Option<&CopperList<P>> {
610        self.inner.as_ref().and_then(CuListsManager::peek)
611    }
612
613    #[cfg(feature = "remote-debug")]
614    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
615        self.last_completed_encoded.as_deref()
616    }
617
618    #[cfg(not(feature = "remote-debug"))]
619    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
620        None
621    }
622
623    #[cfg(feature = "remote-debug")]
624    pub fn set_last_completed_encoded(&mut self, snapshot: Option<Vec<u8>>) {
625        self.last_completed_encoded = snapshot;
626    }
627
628    #[cfg(not(feature = "remote-debug"))]
629    pub fn set_last_completed_encoded(&mut self, _snapshot: Option<Vec<u8>>) {}
630
631    pub fn create(&mut self) -> CuResult<&mut CopperList<P>>
632    where
633        P: CuListZeroedInit,
634    {
635        self.inner
636            .as_mut()
637            .expect("serial storage")
638            .create()
639            .ok_or_else(|| CuError::from("Ran out of space for copper lists"))
640    }
641
642    pub fn end_of_processing(&mut self, culistid: u64) -> CuResult<()> {
643        #[cfg(debug_assertions)]
644        self.debug_assert_end_of_processing_target(culistid);
645
646        let mut is_top = true;
647        let mut nb_done = 0;
648        self.last_encoded_bytes = 0;
649        self.last_handle_bytes = 0;
650        #[cfg(feature = "remote-debug")]
651        let last_completed_encoded = &mut self.last_completed_encoded;
652        for cl in self.inner.as_mut().expect("serial storage").iter_mut() {
653            if cl.id == culistid && cl.get_state() == CopperListState::Processing {
654                cl.change_state(CopperListState::DoneProcessing);
655                #[cfg(feature = "remote-debug")]
656                {
657                    *last_completed_encoded = Some(encode_completed_copperlist_snapshot(cl)?);
658                }
659            }
660            if is_top && cl.get_state() == CopperListState::DoneProcessing {
661                if let Some(sink) = &mut self.sink {
662                    cl.change_state(CopperListState::BeingSerialized);
663                    sink.log(cl)?;
664                    self.last_encoded_bytes = sink.last_log_bytes().unwrap_or(0) as u64;
665                    self.last_handle_bytes = take_last_completed_handle_bytes();
666                }
667                cl.change_state(CopperListState::Free);
668                nb_done += 1;
669            } else {
670                is_top = false;
671            }
672        }
673        for _ in 0..nb_done {
674            let _ = self.inner.as_mut().expect("serial storage").pop();
675        }
676        Ok(())
677    }
678
679    pub fn finish_pending(&mut self) -> CuResult<()> {
680        Ok(())
681    }
682
683    pub fn available_copper_lists(&mut self) -> CuResult<usize> {
684        if let Some(inner) = &self.inner {
685            return Ok(NBCL - inner.len());
686        }
687        #[cfg(feature = "std")]
688        {
689            Ok(self.free_pool.len())
690        }
691        #[cfg(not(feature = "std"))]
692        {
693            Ok(0)
694        }
695    }
696
697    #[inline]
698    pub const fn dropped_copperlists_total(&self) -> u64 {
699        0
700    }
701
702    #[cfg(feature = "std")]
703    pub fn submit_slot(
704        &mut self,
705        mut culist: CuSlotLease<P>,
706    ) -> CuResult<OwnedCopperListSubmission<P>> {
707        #[cfg(debug_assertions)]
708        debug_assert_processing_completion_state(culist.as_ref(), "sync slot completion");
709
710        self.note_admission(culist.id + 1);
711        culist.change_state(CopperListState::DoneProcessing);
712        self.last_keyframe_bytes = 0;
713        if let Some(sink) = &mut self.keyframe_sink
714            && let Some(keyframe) = culist.keyframe().filter(|keyframe| keyframe.is_active())
715        {
716            sink.log(keyframe)?;
717            self.last_keyframe_bytes = sink.last_log_bytes().unwrap_or(0) as u64;
718        }
719        self.last_encoded_bytes = 0;
720        self.last_handle_bytes = 0;
721        if let Some(sink) = &mut self.sink {
722            culist.change_state(CopperListState::BeingSerialized);
723            sink.log(&culist)?;
724            self.last_encoded_bytes = sink.last_log_bytes().unwrap_or(0) as u64;
725            self.last_handle_bytes = take_last_completed_handle_bytes();
726        }
727        culist.change_state(CopperListState::Free);
728        Ok(OwnedCopperListSubmission::Recycled(culist))
729    }
730
731    #[cfg(feature = "std")]
732    pub fn try_reclaim_slot(&mut self) -> CuResult<Option<CuSlotLease<P>>> {
733        Ok(None)
734    }
735
736    #[cfg(feature = "std")]
737    pub const fn has_pending_slots(&self) -> bool {
738        false
739    }
740
741    #[cfg(feature = "std")]
742    pub fn wait_reclaim_slot(&mut self) -> CuResult<CuSlotLease<P>> {
743        Err(CuError::from(
744            "Synchronous CopperList I/O cannot block waiting for slot completions",
745        ))
746    }
747
748    #[cfg(feature = "std")]
749    pub fn finish_pending_slots(&mut self) -> CuResult<Vec<CuSlotLease<P>>> {
750        Ok(Vec::new())
751    }
752
753    #[cfg(feature = "std")]
754    pub fn finish_pending_with(&mut self, _reclaim: impl FnMut(CuSlotLease<P>)) -> CuResult<()> {
755        Ok(())
756    }
757
758    #[cfg(debug_assertions)]
759    fn debug_assert_end_of_processing_target(&self, culistid: u64) {
760        let mut matches = 0usize;
761        let mut state = None;
762        for cl in self.inner.as_ref().expect("serial storage").iter() {
763            if cl.id == culistid {
764                matches += 1;
765                state = Some(cl.get_state());
766            }
767        }
768
769        assert_eq!(
770            matches, 1,
771            "sync end_of_processing expected exactly one active CopperList #{culistid}, found {matches}"
772        );
773        assert_eq!(
774            state,
775            Some(CopperListState::Processing),
776            "sync end_of_processing expected CopperList #{culistid} to be Processing, found {:?}",
777            state
778        );
779    }
780}
781
782#[cfg(feature = "std")]
783impl<P: CopperListTuple + Default, const NBCL: usize> SyncCopperListsManager<P, NBCL> {
784    pub fn acquire_iteration_slot(&mut self) -> CuResult<CuSlotLease<P>> {
785        self.free_pool
786            .pop()
787            .ok_or_else(|| CuError::from("CopperList arena has no free slot"))
788    }
789
790    pub fn prepare_keyframes(&mut self, capacities: &[usize]) {
791        if !capacities.is_empty()
792            && let Some(slot) = self.free_pool.first_mut()
793        {
794            slot.prepare_keyframes(capacities);
795        }
796    }
797
798    pub fn recycle_slot(&mut self, slot: CuSlotLease<P>) {
799        self.free_pool.push(slot);
800    }
801
802    pub fn take_execution_slots(&mut self) -> Vec<CuSlotLease<P>> {
803        core::mem::take(&mut self.free_pool)
804    }
805
806    pub fn return_execution_slots(&mut self, slots: Vec<CuSlotLease<P>>) {
807        self.free_pool = slots;
808    }
809}
810
811/// Result of handing an owned boxed CopperList to the runtime-side CL I/O path.
812#[cfg(feature = "std")]
813#[doc(hidden)]
814pub enum OwnedCopperListSubmission<P: CopperListTuple> {
815    /// The CL has been fully handled and can be recycled immediately by the caller.
816    Recycled(CuSlotLease<P>),
817    /// The CL was queued asynchronously and will be returned by a later reclaim call.
818    Pending,
819}
820
821#[cfg(all(feature = "std", feature = "async-cl-io"))]
822struct AsyncCopperListCompletion<P: CopperListTuple> {
823    culist: CuSlotLease<P>,
824    sink_result: CuResult<(u64, u64, u64)>,
825    #[cfg(feature = "remote-debug")]
826    completed_snapshot: CuResult<Vec<u8>>,
827}
828
829#[cfg(all(feature = "std", feature = "async-cl-io"))]
830struct AsyncOutputWorkerRunningGuard(Arc<AtomicBool>);
831
832#[cfg(all(feature = "std", feature = "async-cl-io"))]
833impl Drop for AsyncOutputWorkerRunningGuard {
834    fn drop(&mut self) {
835        self.0.store(false, Ordering::Release);
836    }
837}
838
839/// Manages the lifecycle and completed-list sink on the asynchronous path.
840#[cfg(all(feature = "std", feature = "async-cl-io"))]
841#[doc(hidden)]
842pub struct AsyncCopperListsManager<P: CopperListTuple + Default, const NBCL: usize> {
843    free_pool: Vec<CuSlotLease<P>>,
844    current: Option<CuSlotLease<P>>,
845    #[cfg(feature = "remote-debug")]
846    last_completed_encoded: Option<Vec<u8>>,
847    pending_count: usize,
848    next_cl_id: u64,
849    pending_producer: Option<Producer<CuSlotLease<P>>>,
850    completion_consumer: Option<Consumer<AsyncCopperListCompletion<P>>>,
851    worker_handle: Option<JoinHandle<()>>,
852    worker_thread: Option<Thread>,
853    worker_shutdown: Option<Arc<AtomicBool>>,
854    worker_running: Option<Arc<AtomicBool>>,
855    dropped_copperlists_total: u64,
856    /// Last local-log encoded size reported by the sink.
857    pub last_encoded_bytes: u64,
858    /// Last handle-backed payload bytes observed while the sink ran.
859    pub last_handle_bytes: u64,
860    pub last_keyframe_bytes: u64,
861    distributed: bool,
862}
863
864#[cfg(all(feature = "std", feature = "async-cl-io"))]
865impl<P: CopperListTuple + Default, const NBCL: usize> AsyncCopperListsManager<P, NBCL> {
866    #[doc(hidden)]
867    pub fn close_output(&mut self) -> CuResult<()> {
868        self.shutdown_worker()
869    }
870
871    pub fn new(sink: Option<Box<CompletedCopperListSink<P>>>) -> CuResult<Self>
872    where
873        P: CuListZeroedInit + AsyncCopperListPayload + 'static,
874    {
875        Self::new_with_keyframes(sink, None)
876    }
877
878    pub fn new_with_keyframes(
879        sink: Option<Box<CompletedCopperListSink<P>>>,
880        keyframe_sink: Option<Box<CompletedKeyFrameSink>>,
881    ) -> CuResult<Self>
882    where
883        P: CuListZeroedInit + AsyncCopperListPayload + 'static,
884    {
885        let free_pool = allocate_slots::<P>(NBCL);
886
887        if sink.is_some() && !P::DISTRIBUTED && NBCL < 2 {
888            return Err(CuError::from(
889                "async CopperList output requires at least two CopperList slots",
890            ));
891        }
892
893        let (
894            pending_producer,
895            completion_consumer,
896            worker_handle,
897            worker_thread,
898            worker_shutdown,
899            worker_running,
900        ) = if sink.is_some() || keyframe_sink.is_some() {
901            let mut sink = sink;
902            let mut keyframe_sink = keyframe_sink;
903            let handoff_capacity = NBCL.max(1);
904            let (pending_producer, mut pending_consumer) =
905                RingBuffer::<CuSlotLease<P>>::new(handoff_capacity);
906            let (mut completion_producer, completion_consumer) =
907                RingBuffer::<AsyncCopperListCompletion<P>>::new(handoff_capacity);
908            let worker_shutdown = Arc::new(AtomicBool::new(false));
909            let worker_running = Arc::new(AtomicBool::new(true));
910            let shutdown = worker_shutdown.clone();
911            let running = worker_running.clone();
912            let worker_handle = std::thread::Builder::new()
913                .name("cu-async-cl-io".to_string())
914                .spawn(move || {
915                    let _running_guard = AsyncOutputWorkerRunningGuard(running);
916                    loop {
917                        let mut culist = match pending_consumer.pop() {
918                            Ok(culist) => culist,
919                            Err(PopError::Empty) => {
920                                if shutdown.load(Ordering::Acquire) {
921                                    break;
922                                }
923                                std::thread::park();
924                                continue;
925                            }
926                        };
927                        #[cfg(feature = "remote-debug")]
928                        let completed_snapshot = {
929                            // Preserve the pre-handoff snapshot contract while
930                            // keeping its allocation and encoding off the RT path.
931                            culist.change_state(CopperListState::DoneProcessing);
932                            encode_completed_copperlist_snapshot(&culist)
933                        };
934                        culist.change_state(CopperListState::BeingSerialized);
935                        let sink_result = (|| {
936                            let mut keyframe_bytes = 0;
937                            if let Some(keyframe_sink) = &mut keyframe_sink
938                                && let Some(keyframe) =
939                                    culist.keyframe().filter(|keyframe| keyframe.is_active())
940                            {
941                                keyframe_sink.log(keyframe)?;
942                                keyframe_bytes = keyframe_sink.last_log_bytes().unwrap_or(0) as u64;
943                            }
944                            if let Some(sink) = &mut sink {
945                                sink.log(&culist)?;
946                                Ok((
947                                    sink.last_log_bytes().unwrap_or(0) as u64,
948                                    take_last_completed_handle_bytes(),
949                                    keyframe_bytes,
950                                ))
951                            } else {
952                                Ok((0, 0, keyframe_bytes))
953                            }
954                        })();
955                        let should_stop = sink_result.is_err();
956                        #[cfg(feature = "remote-debug")]
957                        let should_stop = should_stop || completed_snapshot.is_err();
958                        let mut completion = AsyncCopperListCompletion {
959                            culist,
960                            sink_result,
961                            #[cfg(feature = "remote-debug")]
962                            completed_snapshot,
963                        };
964                        loop {
965                            match completion_producer.push(completion) {
966                                Ok(()) => break,
967                                Err(PushError::Full(returned)) => {
968                                    completion = returned;
969                                    std::thread::yield_now();
970                                }
971                            }
972                        }
973                        if should_stop {
974                            break;
975                        }
976                    }
977                })
978                .map_err(|e| {
979                    CuError::from("Failed to spawn async CopperList serializer thread")
980                        .add_cause(e.to_string().as_str())
981                })?;
982            let worker_thread = worker_handle.thread().clone();
983            (
984                Some(pending_producer),
985                Some(completion_consumer),
986                Some(worker_handle),
987                Some(worker_thread),
988                Some(worker_shutdown),
989                Some(worker_running),
990            )
991        } else {
992            (None, None, None, None, None, None)
993        };
994
995        Ok(Self {
996            free_pool,
997            current: None,
998            #[cfg(feature = "remote-debug")]
999            last_completed_encoded: None,
1000            pending_count: 0,
1001            next_cl_id: 0,
1002            pending_producer,
1003            completion_consumer,
1004            worker_handle,
1005            worker_thread,
1006            worker_shutdown,
1007            worker_running,
1008            dropped_copperlists_total: 0,
1009            last_encoded_bytes: 0,
1010            last_handle_bytes: 0,
1011            last_keyframe_bytes: 0,
1012            distributed: P::DISTRIBUTED,
1013        })
1014    }
1015
1016    pub fn acquire_iteration_slot(&mut self) -> CuResult<CuSlotLease<P>> {
1017        self.reclaim_completed()?;
1018        match self.free_pool.pop() {
1019            Some(slot) => Ok(slot),
1020            None => self.wait_reclaim_slot(),
1021        }
1022    }
1023
1024    pub fn prepare_keyframes(&mut self, capacities: &[usize]) {
1025        if !capacities.is_empty()
1026            && let Some(slot) = self.free_pool.first_mut()
1027        {
1028            slot.prepare_keyframes(capacities);
1029        }
1030    }
1031
1032    pub fn recycle_slot(&mut self, slot: CuSlotLease<P>) {
1033        self.free_pool.push(slot);
1034    }
1035
1036    pub fn take_execution_slots(&mut self) -> Vec<CuSlotLease<P>> {
1037        core::mem::take(&mut self.free_pool)
1038    }
1039
1040    pub fn return_execution_slots(&mut self, slots: Vec<CuSlotLease<P>>) {
1041        self.free_pool = slots;
1042    }
1043
1044    pub fn note_admission(&mut self, next_id: u64) {
1045        self.next_cl_id = self.next_cl_id.max(next_id);
1046    }
1047
1048    pub fn next_cl_id(&self) -> u64 {
1049        self.next_cl_id
1050    }
1051
1052    /// Drains offline replay output before moving to a validated recorded boundary.
1053    /// This may wait for the worker; generated production task steps never call it.
1054    pub fn prepare_recorded_replay(&mut self, next_id: u64) -> CuResult<()> {
1055        self.finish_pending()?;
1056        self.next_cl_id = next_id;
1057        Ok(())
1058    }
1059
1060    pub fn last_cl_id(&self) -> u64 {
1061        self.next_cl_id.saturating_sub(1)
1062    }
1063
1064    pub fn peek(&self) -> Option<&CopperList<P>> {
1065        self.current.as_deref()
1066    }
1067
1068    #[cfg(feature = "remote-debug")]
1069    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
1070        self.last_completed_encoded.as_deref()
1071    }
1072
1073    #[cfg(not(feature = "remote-debug"))]
1074    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
1075        None
1076    }
1077
1078    #[cfg(feature = "remote-debug")]
1079    pub fn set_last_completed_encoded(&mut self, snapshot: Option<Vec<u8>>) {
1080        self.last_completed_encoded = snapshot;
1081    }
1082
1083    #[cfg(not(feature = "remote-debug"))]
1084    pub fn set_last_completed_encoded(&mut self, _snapshot: Option<Vec<u8>>) {}
1085
1086    pub fn create(&mut self) -> CuResult<&mut CopperList<P>>
1087    where
1088        P: CuListZeroedInit,
1089    {
1090        if self.current.is_some() {
1091            return Err(CuError::from(
1092                "Attempted to create a CopperList while another one is still active",
1093            ));
1094        }
1095
1096        self.reclaim_completed()?;
1097
1098        let culist = self.free_pool.pop().ok_or_else(|| {
1099            CuError::from("CopperList output handoff exhausted the slot reserved for execution")
1100        })?;
1101        self.current = Some(culist);
1102
1103        let current = self
1104            .current
1105            .as_mut()
1106            .expect("current CopperList is missing");
1107        current.reset(self.next_cl_id);
1108        self.next_cl_id += 1;
1109        Ok(current.as_mut())
1110    }
1111
1112    pub fn end_of_processing(&mut self, culistid: u64) -> CuResult<()> {
1113        self.reclaim_completed()?;
1114
1115        let mut culist = self.current.take().ok_or_else(|| {
1116            CuError::from("Attempted to finish processing without an active CopperList")
1117        })?;
1118
1119        if culist.id != culistid {
1120            return Err(CuError::from(format!(
1121                "Attempted to finish CopperList #{culistid} while CopperList #{} is active",
1122                culist.id
1123            )));
1124        }
1125        #[cfg(debug_assertions)]
1126        debug_assert_processing_completion_state(culist.as_ref(), "async end_of_processing");
1127
1128        culist.change_state(CopperListState::DoneProcessing);
1129        self.last_encoded_bytes = 0;
1130        self.last_handle_bytes = 0;
1131
1132        match self.try_submit(culist)? {
1133            OwnedCopperListSubmission::Recycled(culist) => self.free_pool.push(culist),
1134            OwnedCopperListSubmission::Pending => {}
1135        }
1136
1137        Ok(())
1138    }
1139
1140    pub fn finish_pending(&mut self) -> CuResult<()> {
1141        if self.current.is_some() {
1142            return Err(CuError::from(
1143                "Cannot flush CopperList I/O while a CopperList is still active",
1144            ));
1145        }
1146
1147        while self.pending_count > 0 {
1148            self.wait_for_completion()?;
1149        }
1150        Ok(())
1151    }
1152
1153    pub fn available_copper_lists(&mut self) -> CuResult<usize> {
1154        self.reclaim_completed()?;
1155        Ok(self.free_pool.len())
1156    }
1157
1158    #[inline]
1159    pub const fn dropped_copperlists_total(&self) -> u64 {
1160        self.dropped_copperlists_total
1161    }
1162
1163    pub fn submit_slot(
1164        &mut self,
1165        mut culist: CuSlotLease<P>,
1166    ) -> CuResult<OwnedCopperListSubmission<P>> {
1167        #[cfg(debug_assertions)]
1168        debug_assert_processing_completion_state(culist.as_ref(), "async slot completion");
1169        self.next_cl_id = self.next_cl_id.max(culist.id + 1);
1170        culist.change_state(CopperListState::DoneProcessing);
1171        self.last_encoded_bytes = 0;
1172        self.last_handle_bytes = 0;
1173
1174        self.try_submit(culist)
1175    }
1176
1177    fn try_submit(&mut self, mut culist: CuSlotLease<P>) -> CuResult<OwnedCopperListSubmission<P>> {
1178        let Some(pending_producer) = self.pending_producer.as_mut() else {
1179            culist.change_state(CopperListState::Free);
1180            return Ok(OwnedCopperListSubmission::Recycled(culist));
1181        };
1182
1183        // Keep one of the preallocated CopperLists available to execute the
1184        // next iteration. Queue capacity alone cannot enforce this because a
1185        // record may be in the worker or waiting on the completion channel.
1186        if !self.distributed && self.pending_count >= NBCL - 1 {
1187            return Ok(self.drop_copperlist(culist));
1188        }
1189
1190        culist.change_state(CopperListState::QueuedForSerialization);
1191        match pending_producer.push(culist) {
1192            Ok(()) => {
1193                self.pending_count += 1;
1194                if let Some(worker_thread) = self.worker_thread.as_ref() {
1195                    worker_thread.unpark();
1196                }
1197                Ok(OwnedCopperListSubmission::Pending)
1198            }
1199            Err(PushError::Full(culist)) => Ok(self.drop_copperlist(culist)),
1200        }
1201    }
1202
1203    fn drop_copperlist(&mut self, mut culist: CuSlotLease<P>) -> OwnedCopperListSubmission<P> {
1204        self.dropped_copperlists_total = self.dropped_copperlists_total.saturating_add(1);
1205        culist.change_state(CopperListState::Free);
1206        OwnedCopperListSubmission::Recycled(culist)
1207    }
1208
1209    pub fn try_reclaim_slot(&mut self) -> CuResult<Option<CuSlotLease<P>>> {
1210        let pop_result = {
1211            let Some(completion_consumer) = self.completion_consumer.as_mut() else {
1212                return Ok(None);
1213            };
1214            completion_consumer.pop()
1215        };
1216        match pop_result {
1217            Ok(completion) => self.handle_completion(completion).map(Some),
1218            Err(PopError::Empty) => {
1219                if self.pending_count > 0
1220                    && self
1221                        .worker_running
1222                        .as_ref()
1223                        .is_some_and(|running| !running.load(Ordering::Acquire))
1224                {
1225                    Err(CuError::from(
1226                        "Async CopperList output worker stopped unexpectedly",
1227                    ))
1228                } else {
1229                    Ok(None)
1230                }
1231            }
1232        }
1233    }
1234
1235    pub fn has_pending_slots(&self) -> bool {
1236        self.pending_count > 0
1237    }
1238
1239    pub fn wait_reclaim_slot(&mut self) -> CuResult<CuSlotLease<P>> {
1240        if self.completion_consumer.is_none() {
1241            return Err(CuError::from(
1242                "No async CopperList output worker is active to return a free slot",
1243            ));
1244        }
1245        loop {
1246            if let Some(culist) = self.try_reclaim_slot()? {
1247                return Ok(culist);
1248            }
1249            std::thread::yield_now();
1250        }
1251    }
1252
1253    pub fn finish_pending_slots(&mut self) -> CuResult<Vec<CuSlotLease<P>>> {
1254        let mut reclaimed = Vec::with_capacity(self.pending_count);
1255        if self.current.is_some() {
1256            return Err(CuError::from(
1257                "Cannot flush CopperList I/O while a CopperList is still active",
1258            ));
1259        }
1260        while self.pending_count > 0 {
1261            reclaimed.push(self.wait_reclaim_slot()?);
1262        }
1263        Ok(reclaimed)
1264    }
1265
1266    pub fn finish_pending_with(&mut self, mut reclaim: impl FnMut(CuSlotLease<P>)) -> CuResult<()> {
1267        if self.current.is_some() {
1268            return Err(CuError::from(
1269                "Cannot flush CopperList I/O while a CopperList is still active",
1270            ));
1271        }
1272        while self.pending_count > 0 {
1273            reclaim(self.wait_reclaim_slot()?);
1274        }
1275        Ok(())
1276    }
1277
1278    fn reclaim_completed(&mut self) -> CuResult<()> {
1279        loop {
1280            let Some(culist) = self.try_reclaim_slot()? else {
1281                break;
1282            };
1283            self.free_pool.push(culist);
1284        }
1285        Ok(())
1286    }
1287
1288    fn wait_for_completion(&mut self) -> CuResult<()> {
1289        let culist = self.wait_reclaim_slot()?;
1290        self.free_pool.push(culist);
1291        Ok(())
1292    }
1293
1294    fn handle_completion(
1295        &mut self,
1296        mut completion: AsyncCopperListCompletion<P>,
1297    ) -> CuResult<CuSlotLease<P>> {
1298        self.pending_count = self.pending_count.saturating_sub(1);
1299        if let Ok((encoded_bytes, handle_bytes, keyframe_bytes)) = completion.sink_result.as_ref() {
1300            self.last_encoded_bytes = *encoded_bytes;
1301            self.last_handle_bytes = *handle_bytes;
1302            self.last_keyframe_bytes = *keyframe_bytes;
1303        }
1304        completion.culist.change_state(CopperListState::Free);
1305        completion.sink_result?;
1306        #[cfg(feature = "remote-debug")]
1307        {
1308            self.last_completed_encoded = Some(completion.completed_snapshot?);
1309        }
1310        Ok(completion.culist)
1311    }
1312
1313    fn shutdown_worker(&mut self) -> CuResult<()> {
1314        self.finish_pending()?;
1315        if let Some(shutdown) = self.worker_shutdown.as_ref() {
1316            shutdown.store(true, Ordering::Release);
1317        }
1318        if let Some(worker_thread) = self.worker_thread.as_ref() {
1319            worker_thread.unpark();
1320        }
1321        if let Some(worker_handle) = self.worker_handle.take() {
1322            worker_handle.join().map_err(|_| {
1323                CuError::from("Async CopperList output worker panicked while joining")
1324            })?;
1325        }
1326        self.pending_producer.take();
1327        self.worker_thread.take();
1328        self.worker_shutdown.take();
1329        self.worker_running.take();
1330        Ok(())
1331    }
1332}
1333
1334#[cfg(all(feature = "std", feature = "async-cl-io"))]
1335impl<P: CopperListTuple + Default, const NBCL: usize> Drop for AsyncCopperListsManager<P, NBCL> {
1336    fn drop(&mut self) {
1337        let _ = self.shutdown_worker();
1338    }
1339}
1340
1341#[cfg(all(feature = "std", debug_assertions))]
1342fn debug_assert_processing_completion_state<P: CopperListTuple>(
1343    culist: &CopperList<P>,
1344    context: &str,
1345) {
1346    assert_eq!(
1347        culist.get_state(),
1348        CopperListState::Processing,
1349        "{context} expected CopperList #{} to be Processing, found {}",
1350        culist.id,
1351        culist.get_state()
1352    );
1353}
1354
1355#[cfg(all(feature = "std", feature = "async-cl-io"))]
1356#[doc(hidden)]
1357pub type CopperListsManager<P, const NBCL: usize> = AsyncCopperListsManager<P, NBCL>;
1358
1359#[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1360#[doc(hidden)]
1361pub type CopperListsManager<P, const NBCL: usize> = SyncCopperListsManager<P, NBCL>;
1362
1363#[cfg(all(feature = "std", feature = "async-cl-io"))]
1364struct AsyncKeyFrameCompletion {
1365    keyframe: Box<KeyFrame>,
1366    sink_result: CuResult<u64>,
1367}
1368
1369/// Manages bounded task-state keyframe capture and output.
1370pub struct KeyFramesManager {
1371    distributed: bool,
1372    /// Per-component region capacities in generated restore order.
1373    #[doc(hidden)]
1374    pub capture_capacities: Vec<usize>,
1375    /// Active capture buffer. It is absent when no downstream consumer needs keyframes.
1376    inner: Option<KeyFrame>,
1377
1378    /// Optional override for the timestamp to stamp the next keyframe (used by deterministic replay).
1379    forced_timestamp: Option<CuTime>,
1380
1381    /// If set, reuse this keyframe verbatim (e.g., during replay) instead of re-freezing state.
1382    locked: bool,
1383
1384    /// Consumer of completed task-state keyframes on the synchronous path.
1385    #[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1386    sink: Option<Box<CompletedKeyFrameSink>>,
1387
1388    /// Spare capture buffers exchanged with completed keyframes at handoff.
1389    /// Boxing is intentional: ownership moves through the SPSC ring without a
1390    /// hot-path allocation or copying the potentially large payload buffer.
1391    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1392    #[allow(clippy::vec_box)]
1393    spares: Vec<Box<KeyFrame>>,
1394    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1395    pending_count: usize,
1396    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1397    pending_producer: Option<Producer<Box<KeyFrame>>>,
1398    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1399    completion_consumer: Option<Consumer<AsyncKeyFrameCompletion>>,
1400    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1401    worker_handle: Option<JoinHandle<()>>,
1402    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1403    worker_thread: Option<Thread>,
1404    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1405    worker_shutdown: Option<Arc<AtomicBool>>,
1406    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1407    worker_running: Option<Arc<AtomicBool>>,
1408    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1409    capture_this_copperlist: Option<u64>,
1410    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1411    dropped_keyframes_total: u64,
1412
1413    /// Capture a keyframe at this CopperList interval.
1414    keyframe_interval: u32,
1415
1416    /// Bytes written by the last completed keyframe output.
1417    pub last_encoded_bytes: u64,
1418
1419    /// Cold-path sizing accumulator used to reserve the capture buffers before execution.
1420    capture_size_hint: usize,
1421}
1422
1423const MIN_KEYFRAME_CAPTURE_CAPACITY: usize = 4 * 1024;
1424#[cfg(all(feature = "std", feature = "async-cl-io"))]
1425const ASYNC_KEYFRAME_HANDOFF_CAPACITY: usize = 2;
1426
1427/// A `Vec` writer that is forbidden from growing its backing allocation.
1428struct PreallocatedVecWriter<'a>(&'a mut Vec<u8>);
1429
1430impl Writer for PreallocatedVecWriter<'_> {
1431    fn write(&mut self, bytes: &[u8]) -> Result<(), EncodeError> {
1432        if bytes.len() > self.0.capacity().saturating_sub(self.0.len()) {
1433            return Err(EncodeError::UnexpectedEnd);
1434        }
1435        // The capacity check above makes this append allocation-free.
1436        self.0.extend_from_slice(bytes);
1437        Ok(())
1438    }
1439}
1440
1441impl KeyFramesManager {
1442    fn distributed(enabled: bool, keyframe_interval: u32) -> CuResult<Self> {
1443        if enabled && keyframe_interval == 0 {
1444            return Err(CuError::from("Keyframe interval cannot be zero"));
1445        }
1446        let mut manager = Self::new(None, keyframe_interval)?;
1447        manager.distributed = enabled;
1448        Ok(manager)
1449    }
1450
1451    #[doc(hidden)]
1452    pub fn capture_interval(&self) -> u32 {
1453        self.keyframe_interval
1454    }
1455
1456    #[doc(hidden)]
1457    pub fn new(sink: Option<Box<CompletedKeyFrameSink>>, keyframe_interval: u32) -> CuResult<Self> {
1458        if sink.is_some() && keyframe_interval == 0 {
1459            return Err(CuError::from(
1460                "Keyframe interval cannot be zero when a downstream consumer requires keyframes",
1461            ));
1462        }
1463
1464        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1465        {
1466            let enabled = sink.is_some();
1467            let (
1468                pending_producer,
1469                completion_consumer,
1470                worker_handle,
1471                worker_thread,
1472                worker_shutdown,
1473                worker_running,
1474            ) = if let Some(mut sink) = sink {
1475                let (pending_producer, mut pending_consumer) =
1476                    RingBuffer::<Box<KeyFrame>>::new(ASYNC_KEYFRAME_HANDOFF_CAPACITY);
1477                let (mut completion_producer, completion_consumer) =
1478                    RingBuffer::<AsyncKeyFrameCompletion>::new(ASYNC_KEYFRAME_HANDOFF_CAPACITY);
1479                let worker_shutdown = Arc::new(AtomicBool::new(false));
1480                let worker_running = Arc::new(AtomicBool::new(true));
1481                let shutdown = worker_shutdown.clone();
1482                let running = worker_running.clone();
1483                let worker_handle = std::thread::Builder::new()
1484                    .name("cu-async-kf-io".to_string())
1485                    .spawn(move || {
1486                        let _running_guard = AsyncOutputWorkerRunningGuard(running);
1487                        loop {
1488                            let keyframe = match pending_consumer.pop() {
1489                                Ok(keyframe) => keyframe,
1490                                Err(PopError::Empty) => {
1491                                    if shutdown.load(Ordering::Acquire) {
1492                                        break;
1493                                    }
1494                                    std::thread::park();
1495                                    continue;
1496                                }
1497                            };
1498                            let sink_result = sink
1499                                .log(keyframe.as_ref())
1500                                .map(|_| sink.last_log_bytes().unwrap_or(0) as u64);
1501                            let should_stop = sink_result.is_err();
1502                            let mut completion = AsyncKeyFrameCompletion {
1503                                keyframe,
1504                                sink_result,
1505                            };
1506                            loop {
1507                                match completion_producer.push(completion) {
1508                                    Ok(()) => break,
1509                                    Err(PushError::Full(returned)) => {
1510                                        completion = returned;
1511                                        std::thread::yield_now();
1512                                    }
1513                                }
1514                            }
1515                            if should_stop {
1516                                break;
1517                            }
1518                        }
1519                    })
1520                    .map_err(|error| {
1521                        CuError::from("Failed to spawn async keyframe output thread")
1522                            .add_cause(error.to_string().as_str())
1523                    })?;
1524                let worker_thread = worker_handle.thread().clone();
1525                (
1526                    Some(pending_producer),
1527                    Some(completion_consumer),
1528                    Some(worker_handle),
1529                    Some(worker_thread),
1530                    Some(worker_shutdown),
1531                    Some(worker_running),
1532                )
1533            } else {
1534                (None, None, None, None, None, None)
1535            };
1536
1537            let spares = if enabled {
1538                let mut spares = Vec::with_capacity(ASYNC_KEYFRAME_HANDOFF_CAPACITY);
1539                for _ in 0..ASYNC_KEYFRAME_HANDOFF_CAPACITY {
1540                    spares.push(Box::new(KeyFrame::new()));
1541                }
1542                spares
1543            } else {
1544                Vec::new()
1545            };
1546            Ok(Self {
1547                distributed: false,
1548                capture_capacities: Vec::new(),
1549                inner: enabled.then(KeyFrame::new),
1550                forced_timestamp: None,
1551                locked: false,
1552                spares,
1553                pending_count: 0,
1554                pending_producer,
1555                completion_consumer,
1556                worker_handle,
1557                worker_thread,
1558                worker_shutdown,
1559                worker_running,
1560                capture_this_copperlist: None,
1561                dropped_keyframes_total: 0,
1562                keyframe_interval,
1563                last_encoded_bytes: 0,
1564                capture_size_hint: KEYFRAME_PAYLOAD_HEADER.len(),
1565            })
1566        }
1567
1568        #[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1569        {
1570            let enabled = sink.is_some();
1571            Ok(Self {
1572                distributed: false,
1573                capture_capacities: Vec::new(),
1574                inner: enabled.then(KeyFrame::new),
1575                forced_timestamp: None,
1576                locked: false,
1577                sink,
1578                keyframe_interval,
1579                last_encoded_bytes: 0,
1580                capture_size_hint: KEYFRAME_PAYLOAD_HEADER.len(),
1581            })
1582        }
1583    }
1584
1585    fn is_keyframe_due(&self, culistid: u64) -> bool {
1586        (self.inner.is_some() || self.distributed)
1587            && culistid.is_multiple_of(self.keyframe_interval as u64)
1588    }
1589
1590    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1591    fn is_capturing(&self, culistid: u64) -> bool {
1592        self.capture_this_copperlist == Some(culistid)
1593    }
1594
1595    #[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1596    fn is_capturing(&self, culistid: u64) -> bool {
1597        self.is_keyframe_due(culistid)
1598    }
1599
1600    #[inline]
1601    pub fn captures_keyframe(&self, culistid: u64) -> bool {
1602        self.is_keyframe_due(culistid)
1603    }
1604
1605    /// Start a cold-path sizing pass for the next mission's keyframe buffer.
1606    #[doc(hidden)]
1607    pub fn begin_capture_preallocation(&mut self) {
1608        self.capture_size_hint = KEYFRAME_PAYLOAD_HEADER.len();
1609        self.capture_capacities.clear();
1610    }
1611
1612    /// Include one component's current frozen size in the cold-path capacity estimate.
1613    #[doc(hidden)]
1614    pub fn include_capture_capacity(&mut self, item: &impl Freezable) -> CuResult<()> {
1615        if self.inner.is_none() && !self.distributed {
1616            return Ok(());
1617        }
1618        let mut sizer = EncoderImpl::new(SizeWriter::default(), bincode::config::standard());
1619        BincodeAdapter(item)
1620            .encode(&mut sizer)
1621            .map_err(|_| CuError::from("Failed to size component keyframe state"))?;
1622        let payload_bytes = sizer.into_writer().bytes_written as usize;
1623        if self.distributed {
1624            self.capture_capacities.push(
1625                (KEYFRAME_FRAME_HEADER_LEN + payload_bytes)
1626                    .max(DISTRIBUTED_REGION_ALIGN)
1627                    .checked_next_power_of_two()
1628                    .ok_or_else(|| CuError::from("Keyframe region size overflow"))?,
1629            );
1630        }
1631        self.capture_size_hint = self
1632            .capture_size_hint
1633            .checked_add(KEYFRAME_FRAME_HEADER_LEN)
1634            .and_then(|size| size.checked_add(payload_bytes))
1635            .ok_or_else(|| CuError::from("Keyframe capture capacity overflow"))?;
1636        Ok(())
1637    }
1638
1639    /// Reserve the capture buffer before entering the execution loop.
1640    #[doc(hidden)]
1641    pub fn finish_capture_preallocation(&mut self) -> CuResult<()> {
1642        if self.distributed {
1643            return Ok(());
1644        }
1645        if self.inner.is_none() {
1646            return Ok(());
1647        }
1648        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1649        self.reclaim_completed()?;
1650        let requested = self
1651            .capture_size_hint
1652            .max(MIN_KEYFRAME_CAPTURE_CAPACITY)
1653            .checked_next_power_of_two()
1654            .ok_or_else(|| CuError::from("Keyframe capture capacity overflow"))?;
1655        reserve_keyframe_capacity(self.inner.as_mut().unwrap(), requested)?;
1656        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1657        for spare in &mut self.spares {
1658            reserve_keyframe_capacity(spare, requested)?;
1659        }
1660        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1661        if self.spares.len() != ASYNC_KEYFRAME_HANDOFF_CAPACITY {
1662            return Err(CuError::from(
1663                "Keyframe output worker did not return every capture buffer before preallocation",
1664            ));
1665        }
1666        Ok(())
1667    }
1668
1669    /// Fallible reset used by generated runtimes so asynchronous worker failures
1670    /// are returned before a new keyframe capture starts.
1671    #[doc(hidden)]
1672    pub fn try_reset(&mut self, culistid: u64, clock: &RobotClock) -> CuResult<()> {
1673        if self.is_keyframe_due(culistid) {
1674            #[cfg(all(feature = "std", feature = "async-cl-io"))]
1675            {
1676                self.reclaim_completed()?;
1677                if self.spares.is_empty() {
1678                    self.capture_this_copperlist = None;
1679                    self.dropped_keyframes_total = self.dropped_keyframes_total.saturating_add(1);
1680                    self.forced_timestamp = None;
1681                    self.locked = false;
1682                    return Ok(());
1683                }
1684                self.capture_this_copperlist = Some(culistid);
1685            }
1686            // If a recorded keyframe was preloaded for this CL, keep it as-is.
1687            let inner = self.inner.as_mut().unwrap();
1688            if self.locked && inner.culistid == culistid {
1689                return Ok(());
1690            }
1691            let ts = self.forced_timestamp.take().unwrap_or_else(|| clock.now());
1692            inner.reset(culistid, ts);
1693            self.locked = false;
1694        }
1695        Ok(())
1696    }
1697
1698    /// Reset the capture buffer at a configured keyframe boundary.
1699    ///
1700    /// Generated runtimes use the fallible internal variant to surface output
1701    /// worker failures. This method preserves the existing direct-call API.
1702    pub fn reset(&mut self, culistid: u64, clock: &RobotClock) {
1703        let _ = self.try_reset(culistid, clock);
1704    }
1705
1706    /// Force the timestamp of the next keyframe to a given value.
1707    #[cfg(feature = "std")]
1708    pub fn set_forced_timestamp(&mut self, ts: CuTime) {
1709        self.forced_timestamp = Some(ts);
1710    }
1711
1712    pub fn freeze_task(&mut self, culistid: u64, task: &impl Freezable) -> CuResult<usize> {
1713        if self.is_capturing(culistid) {
1714            if self.locked {
1715                // We are replaying a recorded keyframe verbatim; don't mutate it.
1716                return Ok(0);
1717            }
1718            let inner = self.inner.as_mut().unwrap();
1719            if inner.culistid != culistid {
1720                return Err(CuError::from(format!(
1721                    "Freezing task for culistid {} but current keyframe is {}",
1722                    culistid, inner.culistid
1723                )));
1724            }
1725            let encoded = inner
1726                .add_frozen_task(task)
1727                .map_err(|e| CuError::from(format!("Failed to serialize task: {e}")))?;
1728            Ok(encoded)
1729        } else {
1730            Ok(0)
1731        }
1732    }
1733
1734    /// Generic helper to freeze any `Freezable` state (task or bridge) into the current keyframe.
1735    pub fn freeze_any(&mut self, culistid: u64, item: &impl Freezable) -> CuResult<usize> {
1736        self.freeze_task(culistid, item)
1737    }
1738
1739    pub fn end_of_processing(&mut self, culistid: u64) -> CuResult<()> {
1740        if self.is_capturing(culistid) {
1741            #[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1742            {
1743                let sink = self.sink.as_mut().unwrap();
1744                sink.log(self.inner.as_ref().unwrap())?;
1745                self.last_encoded_bytes = sink.last_log_bytes().unwrap_or(0) as u64;
1746            }
1747            #[cfg(all(feature = "std", feature = "async-cl-io"))]
1748            {
1749                self.last_encoded_bytes = 0;
1750                let mut completed = self.spares.pop().ok_or_else(|| {
1751                    CuError::from("Missing spare keyframe buffer at output handoff")
1752                })?;
1753                core::mem::swap(self.inner.as_mut().unwrap(), completed.as_mut());
1754                let producer = self.pending_producer.as_mut().ok_or_else(|| {
1755                    CuError::from("Missing keyframe output producer for active capture")
1756                })?;
1757                match producer.push(completed) {
1758                    Ok(()) => {
1759                        self.pending_count += 1;
1760                        if let Some(worker_thread) = self.worker_thread.as_ref() {
1761                            worker_thread.unpark();
1762                        }
1763                    }
1764                    Err(PushError::Full(spare)) => {
1765                        self.spares.push(spare);
1766                        self.dropped_keyframes_total =
1767                            self.dropped_keyframes_total.saturating_add(1);
1768                    }
1769                }
1770                self.capture_this_copperlist = None;
1771            }
1772            // Clear the lock so the next CL can rebuild normally unless re-locked.
1773            self.locked = false;
1774            Ok(())
1775        } else {
1776            // Not a keyframe for this CL; ensure we don't carry stale sizes forward.
1777            self.last_encoded_bytes = 0;
1778            Ok(())
1779        }
1780    }
1781
1782    /// Preload a recorded keyframe so it is logged verbatim on the matching CL.
1783    #[cfg(feature = "std")]
1784    pub fn lock_keyframe(&mut self, keyframe: &KeyFrame) {
1785        if let Some(inner) = self.inner.as_mut() {
1786            *inner = keyframe.clone();
1787            self.forced_timestamp = Some(keyframe.timestamp);
1788            self.locked = true;
1789        }
1790    }
1791
1792    #[inline]
1793    #[doc(hidden)]
1794    pub const fn dropped_keyframes_total(&self) -> u64 {
1795        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1796        {
1797            self.dropped_keyframes_total
1798        }
1799        #[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1800        {
1801            0
1802        }
1803    }
1804
1805    #[doc(hidden)]
1806    pub fn close_output(&mut self) -> CuResult<()> {
1807        self.finish_pending()?;
1808        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1809        self.shutdown_worker()?;
1810        #[cfg(not(all(feature = "std", feature = "async-cl-io")))]
1811        {
1812            if let Some(sink) = &mut self.sink {
1813                sink.flush()?;
1814            }
1815            self.sink.take();
1816        }
1817        Ok(())
1818    }
1819
1820    #[doc(hidden)]
1821    pub fn finish_pending(&mut self) -> CuResult<()> {
1822        #[cfg(all(feature = "std", feature = "async-cl-io"))]
1823        while self.pending_count > 0 {
1824            self.wait_for_completion()?;
1825        }
1826        Ok(())
1827    }
1828
1829    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1830    fn reclaim_completed(&mut self) -> CuResult<()> {
1831        let pop_result = {
1832            let Some(completion_consumer) = self.completion_consumer.as_mut() else {
1833                return Ok(());
1834            };
1835            completion_consumer.pop()
1836        };
1837        match pop_result {
1838            Ok(completion) => self.handle_completion(completion),
1839            Err(PopError::Empty) => {
1840                if self.pending_count > 0
1841                    && self
1842                        .worker_running
1843                        .as_ref()
1844                        .is_some_and(|running| !running.load(Ordering::Acquire))
1845                {
1846                    Err(CuError::from(
1847                        "Async keyframe output worker stopped unexpectedly",
1848                    ))
1849                } else {
1850                    Ok(())
1851                }
1852            }
1853        }
1854    }
1855
1856    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1857    fn wait_for_completion(&mut self) -> CuResult<()> {
1858        loop {
1859            let pending_before = self.pending_count;
1860            self.reclaim_completed()?;
1861            if self.pending_count < pending_before {
1862                return Ok(());
1863            }
1864            std::thread::yield_now();
1865        }
1866    }
1867
1868    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1869    fn handle_completion(&mut self, completion: AsyncKeyFrameCompletion) -> CuResult<()> {
1870        self.pending_count = self.pending_count.saturating_sub(1);
1871        if let Ok(encoded_bytes) = completion.sink_result.as_ref() {
1872            self.last_encoded_bytes = *encoded_bytes;
1873        }
1874        self.spares.push(completion.keyframe);
1875        completion.sink_result.map(|_| ())
1876    }
1877
1878    #[cfg(all(feature = "std", feature = "async-cl-io"))]
1879    fn shutdown_worker(&mut self) -> CuResult<()> {
1880        self.finish_pending()?;
1881        if let Some(shutdown) = self.worker_shutdown.as_ref() {
1882            shutdown.store(true, Ordering::Release);
1883        }
1884        if let Some(worker_thread) = self.worker_thread.as_ref() {
1885            worker_thread.unpark();
1886        }
1887        if let Some(worker_handle) = self.worker_handle.take() {
1888            worker_handle.join().map_err(|_| {
1889                CuError::from("Async keyframe output worker panicked while joining")
1890            })?;
1891        }
1892        self.pending_producer.take();
1893        self.worker_thread.take();
1894        self.worker_shutdown.take();
1895        self.worker_running.take();
1896        Ok(())
1897    }
1898}
1899
1900fn reserve_keyframe_capacity(keyframe: &mut KeyFrame, requested: usize) -> CuResult<()> {
1901    if keyframe.serialized_tasks.capacity() < requested {
1902        let additional = requested.saturating_sub(keyframe.serialized_tasks.len());
1903        keyframe
1904            .serialized_tasks
1905            .try_reserve_exact(additional)
1906            .map_err(|error| {
1907                CuError::from("Failed to preallocate keyframe capture buffer")
1908                    .add_cause(&error.to_string())
1909            })?;
1910    }
1911    Ok(())
1912}
1913
1914#[cfg(all(feature = "std", feature = "async-cl-io"))]
1915impl Drop for KeyFramesManager {
1916    fn drop(&mut self) {
1917        let _ = self.shutdown_worker();
1918    }
1919}
1920
1921/// This is the main structure that will be injected as a member of the Application struct.
1922/// CT is the tuple of all the tasks in order of execution.
1923/// CL is the type of the copper list, representing the input/output messages for all the tasks.
1924pub struct CuRuntime<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize> {
1925    /// The base clock the runtime will be using to record time.
1926    clock: RobotClock,
1927
1928    /// Compile-time subsystem identity for this Copper process.
1929    subsystem_code: u16,
1930
1931    /// Deployment/runtime instance identity for this Copper process.
1932    #[doc(hidden)]
1933    pub instance_id: u32,
1934
1935    /// The tuple of all the tasks in order of execution.
1936    #[doc(hidden)]
1937    pub tasks: CT,
1938
1939    /// Tuple of all instantiated bridges.
1940    #[doc(hidden)]
1941    pub bridges: CB,
1942
1943    /// Resource registry kept alive for tasks borrowing shared handles.
1944    #[doc(hidden)]
1945    pub resources: ResourceManager,
1946
1947    /// Rayon thread pools owned by the runtime, indexed positionally to
1948    /// `runtime.thread_pools`. Reserved pools (e.g. `"rt"`) leave `None` slots.
1949    #[cfg(feature = "std")]
1950    #[doc(hidden)]
1951    pub thread_pools: Vec<Option<Arc<ThreadPool>>>,
1952
1953    /// The runtime monitoring.
1954    #[doc(hidden)]
1955    pub monitor: M,
1956
1957    /// Runtime-side execution progress probe for watchdog/diagnostic monitors.
1958    ///
1959    /// This probe is written from the generated execution plan before each component
1960    /// step. Monitors consume it asynchronously (typically from watchdog threads) to
1961    /// report the last known component/step/culist when the runtime appears stalled.
1962    #[cfg(feature = "std")]
1963    #[doc(hidden)]
1964    pub execution_probe: ExecutionProbeHandle,
1965    #[cfg(not(feature = "std"))]
1966    #[doc(hidden)]
1967    pub execution_probe: RuntimeExecutionProbe,
1968
1969    /// Lifecycle manager and completed CopperList output boundary.
1970    #[doc(hidden)]
1971    pub copperlists_manager: CopperListsManager<P, NBCL>,
1972
1973    /// Manager for capturing and consuming task-state keyframes.
1974    #[doc(hidden)]
1975    pub keyframes_manager: KeyFramesManager,
1976
1977    /// Feature-gated container for deterministic multi-CopperList execution.
1978    #[cfg(all(feature = "std", feature = "parallel-rt"))]
1979    #[doc(hidden)]
1980    pub parallel_rt: ParallelRt<NBCL>,
1981
1982    /// The runtime configuration controlling the behavior of the run loop
1983    #[doc(hidden)]
1984    pub runtime_config: RuntimeConfig,
1985}
1986
1987/// To be able to share the clock we make the runtime a clock provider.
1988impl<
1989    CT,
1990    CB,
1991    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload,
1992    M: CuMonitor,
1993    const NBCL: usize,
1994> ClockProvider for CuRuntime<CT, CB, P, M, NBCL>
1995{
1996    fn get_clock(&self) -> RobotClock {
1997        self.clock.clone()
1998    }
1999}
2000
2001impl<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize> CuRuntime<CT, CB, P, M, NBCL> {
2002    /// Returns a clone of the runtime clock handle.
2003    #[inline]
2004    pub fn clock(&self) -> RobotClock {
2005        self.clock.clone()
2006    }
2007
2008    /// Returns the runtime clock by reference for generated runtime code.
2009    #[doc(hidden)]
2010    #[inline]
2011    pub fn clock_ref(&self) -> &RobotClock {
2012        &self.clock
2013    }
2014
2015    /// Returns the compile-time subsystem code for this process.
2016    #[inline]
2017    pub fn subsystem_code(&self) -> u16 {
2018        self.subsystem_code
2019    }
2020
2021    /// Returns the configured runtime instance id for this process.
2022    #[inline]
2023    pub fn instance_id(&self) -> u32 {
2024        self.instance_id
2025    }
2026}
2027
2028#[cfg(feature = "std")]
2029impl<
2030    'cfg,
2031    CT,
2032    CB,
2033    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
2034    M: CuMonitor,
2035    const NBCL: usize,
2036    TI,
2037    BI,
2038    MI,
2039    CLS,
2040    KFS,
2041> CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLS, KFS>
2042where
2043    TI: for<'c> Fn(
2044        Vec<Option<&'c ComponentConfig>>,
2045        &mut ResourceManager,
2046        &[Option<Arc<ThreadPool>>],
2047    ) -> CuResult<CT>,
2048    BI: Fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>,
2049    MI: Fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M,
2050    CLS: WriteStream<CopperList<P>> + 'static,
2051    KFS: WriteStream<KeyFrame> + 'static,
2052{
2053    pub fn build(self) -> CuResult<CuRuntime<CT, CB, P, M, NBCL>> {
2054        let Self {
2055            clock,
2056            config,
2057            mission,
2058            subsystem,
2059            instance_id,
2060            resources,
2061            thread_pools,
2062            parts,
2063            copperlist_sink,
2064            keyframe_sink,
2065            output_requirements,
2066        } = self;
2067        let mut resources =
2068            resources.ok_or_else(|| CuError::from("Resources missing from CuRuntimeBuilder"))?;
2069        let thread_pools = thread_pools.unwrap_or_default();
2070
2071        let graph = config.get_graph(Some(mission))?;
2072        let all_instances_configs: Vec<Option<&ComponentConfig>> = graph
2073            .get_all_nodes()
2074            .iter()
2075            .map(|(_, node)| node.get_instance_config())
2076            .collect();
2077
2078        let tasks =
2079            (parts.tasks_instanciator)(all_instances_configs, &mut resources, &thread_pools)?;
2080
2081        #[cfg(feature = "std")]
2082        let execution_probe = std::sync::Arc::new(RuntimeExecutionProbe::default());
2083        #[cfg(not(feature = "std"))]
2084        let execution_probe = RuntimeExecutionProbe::default();
2085        let monitor_metadata = CuMonitoringMetadata::new(
2086            CompactString::from(mission),
2087            parts.monitored_components,
2088            parts.culist_component_mapping,
2089            CopperListInfo::new(core::mem::size_of::<CopperList<P>>(), NBCL),
2090            build_monitor_topology(config, mission)?,
2091            None,
2092        )?
2093        .with_subsystem_id(subsystem.id())
2094        .with_instance_id(instance_id);
2095        #[cfg(feature = "std")]
2096        let monitor_runtime =
2097            CuMonitoringRuntime::new(MonitorExecutionProbe::from_shared(execution_probe.clone()));
2098        #[cfg(not(feature = "std"))]
2099        let monitor_runtime = CuMonitoringRuntime::unavailable();
2100        let monitor = (parts.monitor_instanciator)(config, monitor_metadata, monitor_runtime);
2101        let bridges = (parts.bridges_instanciator)(config, &mut resources)?;
2102
2103        let copperlist_sink = output_requirements
2104            .completed_copperlists()
2105            .then(|| Box::new(copperlist_sink) as Box<CompletedCopperListSink<P>>);
2106        let keyframe_sink = output_requirements
2107            .keyframes()
2108            .then(|| Box::new(keyframe_sink) as Box<CompletedKeyFrameSink>);
2109        let keyframe_interval = config
2110            .logging
2111            .as_ref()
2112            .and_then(|logging| logging.keyframe_interval)
2113            .unwrap_or(DEFAULT_KEYFRAME_INTERVAL);
2114
2115        let (copperlists_manager, keyframes_manager) = if P::DISTRIBUTED {
2116            let keyframes_manager =
2117                KeyFramesManager::distributed(keyframe_sink.is_some(), keyframe_interval)?;
2118            (
2119                CopperListsManager::new_with_keyframes(copperlist_sink, keyframe_sink)?,
2120                keyframes_manager,
2121            )
2122        } else {
2123            (
2124                CopperListsManager::new(copperlist_sink)?,
2125                KeyFramesManager::new(keyframe_sink, keyframe_interval)?,
2126            )
2127        };
2128        #[cfg(target_os = "none")]
2129        {
2130            let cl_size = core::mem::size_of::<CopperList<P>>();
2131            let total_bytes = cl_size.saturating_mul(NBCL);
2132            info!(
2133                "CuRuntimeBuilder: copperlists count={} cl_size={} total_bytes={}",
2134                NBCL, cl_size, total_bytes
2135            );
2136        }
2137
2138        #[cfg(all(feature = "std", feature = "parallel-rt"))]
2139        let parallel_rt = ParallelRt::new(parts.parallel_rt_metadata)?;
2140
2141        let runtime_config = config.runtime.clone().unwrap_or_default();
2142        runtime_config.validate()?;
2143
2144        Ok(CuRuntime {
2145            subsystem_code: subsystem.code(),
2146            instance_id,
2147            tasks,
2148            bridges,
2149            resources,
2150            thread_pools,
2151            monitor,
2152            execution_probe,
2153            clock,
2154            copperlists_manager,
2155            keyframes_manager,
2156            #[cfg(all(feature = "std", feature = "parallel-rt"))]
2157            parallel_rt,
2158            runtime_config,
2159        })
2160    }
2161}
2162
2163#[cfg(not(feature = "std"))]
2164impl<
2165    'cfg,
2166    CT,
2167    CB,
2168    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
2169    M: CuMonitor,
2170    const NBCL: usize,
2171    TI,
2172    BI,
2173    MI,
2174    CLS,
2175    KFS,
2176> CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLS, KFS>
2177where
2178    TI: for<'c> Fn(Vec<Option<&'c ComponentConfig>>, &mut ResourceManager) -> CuResult<CT>,
2179    BI: Fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>,
2180    MI: Fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M,
2181    CLS: WriteStream<CopperList<P>> + 'static,
2182    KFS: WriteStream<KeyFrame> + 'static,
2183{
2184    pub fn build(self) -> CuResult<CuRuntime<CT, CB, P, M, NBCL>> {
2185        let Self {
2186            clock,
2187            config,
2188            mission,
2189            subsystem,
2190            instance_id,
2191            resources,
2192            parts,
2193            copperlist_sink,
2194            keyframe_sink,
2195            output_requirements,
2196        } = self;
2197        let mut resources =
2198            resources.ok_or_else(|| CuError::from("Resources missing from CuRuntimeBuilder"))?;
2199
2200        let graph = config.get_graph(Some(mission))?;
2201        let all_instances_configs: Vec<Option<&ComponentConfig>> = graph
2202            .get_all_nodes()
2203            .iter()
2204            .map(|(_, node)| node.get_instance_config())
2205            .collect();
2206
2207        let tasks = (parts.tasks_instanciator)(all_instances_configs, &mut resources)?;
2208
2209        let execution_probe = RuntimeExecutionProbe::default();
2210        let monitor_metadata = CuMonitoringMetadata::new(
2211            CompactString::from(mission),
2212            parts.monitored_components,
2213            parts.culist_component_mapping,
2214            CopperListInfo::new(core::mem::size_of::<CopperList<P>>(), NBCL),
2215            build_monitor_topology(config, mission)?,
2216            None,
2217        )?
2218        .with_subsystem_id(subsystem.id())
2219        .with_instance_id(instance_id);
2220        let monitor_runtime = CuMonitoringRuntime::unavailable();
2221        let monitor = (parts.monitor_instanciator)(config, monitor_metadata, monitor_runtime);
2222        let bridges = (parts.bridges_instanciator)(config, &mut resources)?;
2223
2224        let copperlist_sink = output_requirements
2225            .completed_copperlists()
2226            .then(|| Box::new(copperlist_sink) as Box<CompletedCopperListSink<P>>);
2227        let keyframe_sink = output_requirements
2228            .keyframes()
2229            .then(|| Box::new(keyframe_sink) as Box<CompletedKeyFrameSink>);
2230        let keyframe_interval = config
2231            .logging
2232            .as_ref()
2233            .and_then(|logging| logging.keyframe_interval)
2234            .unwrap_or(DEFAULT_KEYFRAME_INTERVAL);
2235
2236        let (copperlists_manager, keyframes_manager) = if P::DISTRIBUTED {
2237            let keyframes_manager =
2238                KeyFramesManager::distributed(keyframe_sink.is_some(), keyframe_interval)?;
2239            (
2240                CopperListsManager::new_with_keyframes(copperlist_sink, keyframe_sink)?,
2241                keyframes_manager,
2242            )
2243        } else {
2244            (
2245                CopperListsManager::new(copperlist_sink)?,
2246                KeyFramesManager::new(keyframe_sink, keyframe_interval)?,
2247            )
2248        };
2249        #[cfg(target_os = "none")]
2250        {
2251            let cl_size = core::mem::size_of::<CopperList<P>>();
2252            let total_bytes = cl_size.saturating_mul(NBCL);
2253            info!(
2254                "CuRuntimeBuilder: copperlists count={} cl_size={} total_bytes={}",
2255                NBCL, cl_size, total_bytes
2256            );
2257        }
2258
2259        let runtime_config = config.runtime.clone().unwrap_or_default();
2260        runtime_config.validate()?;
2261
2262        Ok(CuRuntime {
2263            subsystem_code: subsystem.code(),
2264            instance_id,
2265            tasks,
2266            bridges,
2267            resources,
2268            monitor,
2269            execution_probe,
2270            clock,
2271            copperlists_manager,
2272            keyframes_manager,
2273            runtime_config,
2274        })
2275    }
2276}
2277
2278/// A keyframe records a distributed snapshot of component state around a copperlist.
2279///
2280/// `serialized_tasks` contains a versioned sequence of length-framed component
2281/// snapshots in their generated execution-wave freeze order.
2282#[derive(Clone, Decode)]
2283pub struct KeyFrame {
2284    // This is the id of the copper list that this keyframe is associated with (recorded before the copperlist).
2285    pub culistid: u64,
2286    // This is the timestamp when the keyframe was created, using the robot clock.
2287    pub timestamp: CuTime,
2288    // Versioned, length-framed bincode snapshots of all generated components.
2289    pub serialized_tasks: Vec<u8>,
2290}
2291
2292const DISTRIBUTED_KEYFRAME_HEADER: &[u8; 5] = b"CUKD\x01";
2293const DISTRIBUTED_ACTIVE_OFFSET: usize = DISTRIBUTED_KEYFRAME_HEADER.len();
2294const DISTRIBUTED_COUNT_OFFSET: usize = DISTRIBUTED_ACTIVE_OFFSET + 1;
2295const DISTRIBUTED_TABLE_OFFSET: usize = DISTRIBUTED_COUNT_OFFSET + 4;
2296const DISTRIBUTED_ENTRY_LEN: usize = 16;
2297const DISTRIBUTED_REGION_LEN: usize = 8;
2298const DISTRIBUTED_REGION_ALIGN: usize = 128;
2299
2300struct RegionWriter<'a> {
2301    bytes: &'a mut [u8],
2302    written: usize,
2303}
2304
2305impl Writer for RegionWriter<'_> {
2306    fn write(&mut self, bytes: &[u8]) -> Result<(), EncodeError> {
2307        let end = self
2308            .written
2309            .checked_add(bytes.len())
2310            .ok_or(EncodeError::UnexpectedEnd)?;
2311        self.bytes
2312            .get_mut(self.written..end)
2313            .ok_or(EncodeError::UnexpectedEnd)?
2314            .copy_from_slice(bytes);
2315        self.written = end;
2316        Ok(())
2317    }
2318}
2319
2320fn distributed_count(bytes: &[u8]) -> Result<Option<usize>, &'static str> {
2321    if !bytes.starts_with(DISTRIBUTED_KEYFRAME_HEADER) {
2322        return Ok(None);
2323    }
2324    let count_end = DISTRIBUTED_COUNT_OFFSET + 4;
2325    let count = u32::from_le_bytes(
2326        bytes
2327            .get(DISTRIBUTED_COUNT_OFFSET..count_end)
2328            .ok_or("distributed keyframe header is truncated")?
2329            .try_into()
2330            .map_err(|_| "distributed keyframe count is invalid")?,
2331    ) as usize;
2332    let table_end = DISTRIBUTED_TABLE_OFFSET
2333        .checked_add(
2334            count
2335                .checked_mul(DISTRIBUTED_ENTRY_LEN)
2336                .ok_or("distributed keyframe table overflows")?,
2337        )
2338        .ok_or("distributed keyframe table overflows")?;
2339    if table_end > bytes.len() {
2340        return Err("distributed keyframe table is truncated");
2341    }
2342    Ok(Some(count))
2343}
2344
2345fn distributed_region(bytes: &[u8], component: usize) -> Result<(usize, usize), &'static str> {
2346    let count = distributed_count(bytes)?.ok_or("distributed keyframe storage is unavailable")?;
2347    if component >= count {
2348        return Err("keyframe component is out of bounds");
2349    }
2350    let entry = DISTRIBUTED_TABLE_OFFSET + component * DISTRIBUTED_ENTRY_LEN;
2351    let offset = u64::from_le_bytes(
2352        bytes[entry..entry + 8]
2353            .try_into()
2354            .map_err(|_| "distributed keyframe offset is invalid")?,
2355    );
2356    let capacity = u64::from_le_bytes(
2357        bytes[entry + 8..entry + DISTRIBUTED_ENTRY_LEN]
2358            .try_into()
2359            .map_err(|_| "distributed keyframe capacity is invalid")?,
2360    );
2361    let offset = usize::try_from(offset).map_err(|_| "distributed keyframe offset is too large")?;
2362    let capacity =
2363        usize::try_from(capacity).map_err(|_| "distributed keyframe capacity is too large")?;
2364    let end = offset
2365        .checked_add(DISTRIBUTED_REGION_LEN)
2366        .and_then(|value| value.checked_add(capacity))
2367        .ok_or("distributed keyframe region overflows")?;
2368    if end > bytes.len() {
2369        return Err("distributed keyframe region is truncated");
2370    }
2371    Ok((offset, capacity))
2372}
2373
2374unsafe fn distributed_region_raw(
2375    ptr: *const u8,
2376    allocation_len: usize,
2377    component: usize,
2378) -> Result<(usize, usize), &'static str> {
2379    if allocation_len < DISTRIBUTED_COUNT_OFFSET + 4 {
2380        return Err("distributed keyframe header is truncated");
2381    }
2382    for (index, expected) in DISTRIBUTED_KEYFRAME_HEADER.iter().copied().enumerate() {
2383        // SAFETY: the caller provides an allocation of `allocation_len` bytes,
2384        // and the header bound was checked above.
2385        if unsafe { ptr.add(index).read() } != expected {
2386            return Err("distributed keyframe storage is unavailable");
2387        }
2388    }
2389    // SAFETY: the count field is inside the checked header. It is immutable
2390    // while execution lanes write their disjoint component regions.
2391    let count = u32::from_le(unsafe {
2392        ptr.add(DISTRIBUTED_COUNT_OFFSET)
2393            .cast::<u32>()
2394            .read_unaligned()
2395    }) as usize;
2396    let table_end = DISTRIBUTED_TABLE_OFFSET
2397        .checked_add(
2398            count
2399                .checked_mul(DISTRIBUTED_ENTRY_LEN)
2400                .ok_or("distributed keyframe table overflows")?,
2401        )
2402        .ok_or("distributed keyframe table overflows")?;
2403    if table_end > allocation_len {
2404        return Err("distributed keyframe table is truncated");
2405    }
2406    if component >= count {
2407        return Err("keyframe component is out of bounds");
2408    }
2409    let entry = DISTRIBUTED_TABLE_OFFSET + component * DISTRIBUTED_ENTRY_LEN;
2410    // SAFETY: the entry lies inside the checked immutable descriptor table.
2411    let offset = u64::from_le(unsafe { ptr.add(entry).cast::<u64>().read_unaligned() });
2412    // SAFETY: the capacity immediately follows the checked offset field.
2413    let capacity = u64::from_le(unsafe { ptr.add(entry + 8).cast::<u64>().read_unaligned() });
2414    let offset = usize::try_from(offset).map_err(|_| "distributed keyframe offset is too large")?;
2415    let capacity =
2416        usize::try_from(capacity).map_err(|_| "distributed keyframe capacity is too large")?;
2417    let end = offset
2418        .checked_add(DISTRIBUTED_REGION_LEN)
2419        .and_then(|value| value.checked_add(capacity))
2420        .ok_or("distributed keyframe region overflows")?;
2421    if end > allocation_len {
2422        return Err("distributed keyframe region is truncated");
2423    }
2424    Ok((offset, capacity))
2425}
2426
2427fn distributed_frame(bytes: &[u8], component: usize) -> Result<&[u8], &'static str> {
2428    let (offset, capacity) = distributed_region(bytes, component)?;
2429    let frame_len = u64::from_le_bytes(
2430        bytes[offset..offset + DISTRIBUTED_REGION_LEN]
2431            .try_into()
2432            .map_err(|_| "distributed keyframe length is invalid")?,
2433    );
2434    let frame_len =
2435        usize::try_from(frame_len).map_err(|_| "distributed keyframe length is too large")?;
2436    if !(KEYFRAME_FRAME_HEADER_LEN..=capacity).contains(&frame_len) {
2437        return Err("distributed keyframe component is incomplete");
2438    }
2439    let frame_start = offset + DISTRIBUTED_REGION_LEN;
2440    Ok(&bytes[frame_start..frame_start + frame_len])
2441}
2442
2443fn align_up(value: usize, alignment: usize) -> Option<usize> {
2444    value
2445        .checked_add(alignment - 1)
2446        .map(|value| value & !(alignment - 1))
2447}
2448
2449impl Encode for KeyFrame {
2450    fn encode<__E: bincode::enc::Encoder>(&self, encoder: &mut __E) -> Result<(), EncodeError> {
2451        self.culistid.encode(encoder)?;
2452        self.timestamp.encode(encoder)?;
2453        let bytes = self.serialized_tasks.as_slice();
2454        let Some(count) = distributed_count(bytes).map_err(EncodeError::Other)? else {
2455            return self.serialized_tasks.encode(encoder);
2456        };
2457        let mut length = KEYFRAME_PAYLOAD_HEADER.len();
2458        for component in 0..count {
2459            length = length
2460                .checked_add(
2461                    distributed_frame(bytes, component)
2462                        .map_err(EncodeError::Other)?
2463                        .len(),
2464                )
2465                .ok_or(EncodeError::Other("distributed keyframe length overflows"))?;
2466        }
2467        length.encode(encoder)?;
2468        encoder.writer().write(KEYFRAME_PAYLOAD_HEADER)?;
2469        for component in 0..count {
2470            encoder
2471                .writer()
2472                .write(distributed_frame(bytes, component).map_err(EncodeError::Other)?)?;
2473        }
2474        Ok(())
2475    }
2476}
2477
2478impl KeyFrame {
2479    pub(crate) fn from_serialized(
2480        culistid: u64,
2481        timestamp: CuTime,
2482        serialized_tasks: Vec<u8>,
2483    ) -> Self {
2484        Self {
2485            culistid,
2486            timestamp,
2487            serialized_tasks,
2488        }
2489    }
2490
2491    #[inline]
2492    #[cfg(feature = "std")]
2493    pub(crate) fn is_active(&self) -> bool {
2494        self.serialized_tasks
2495            .starts_with(DISTRIBUTED_KEYFRAME_HEADER)
2496            && self.serialized_tasks[DISTRIBUTED_ACTIVE_OFFSET] != 0
2497    }
2498
2499    /// Build one keyframe with a single cache-aligned backing slab.
2500    #[doc(hidden)]
2501    pub fn distributed(capacities: &[usize]) -> Self {
2502        let table_end = DISTRIBUTED_TABLE_OFFSET
2503            .checked_add(
2504                capacities
2505                    .len()
2506                    .checked_mul(DISTRIBUTED_ENTRY_LEN)
2507                    .expect("distributed keyframe region table overflow"),
2508            )
2509            .expect("distributed keyframe region table overflow");
2510        let slab_bytes = capacities.iter().fold(0usize, |total, &capacity| {
2511            let capacity = capacity.max(KEYFRAME_FRAME_HEADER_LEN);
2512            let region_bytes =
2513                align_up(DISTRIBUTED_REGION_LEN + capacity, DISTRIBUTED_REGION_ALIGN)
2514                    .expect("distributed keyframe region size overflow");
2515            total
2516                .checked_add(region_bytes)
2517                .expect("distributed keyframe slab size overflow")
2518        });
2519        let maximum = table_end
2520            .checked_add(DISTRIBUTED_REGION_ALIGN - 1)
2521            .and_then(|value| value.checked_add(slab_bytes))
2522            .expect("distributed keyframe allocation size overflow");
2523        let mut serialized_tasks = alloc::vec![0; maximum];
2524        serialized_tasks[..DISTRIBUTED_KEYFRAME_HEADER.len()]
2525            .copy_from_slice(DISTRIBUTED_KEYFRAME_HEADER);
2526        let count = u32::try_from(capacities.len()).expect("too many keyframe components");
2527        serialized_tasks[DISTRIBUTED_COUNT_OFFSET..DISTRIBUTED_COUNT_OFFSET + 4]
2528            .copy_from_slice(&count.to_le_bytes());
2529
2530        let allocation = serialized_tasks.as_ptr() as usize;
2531        let mut region_offset = align_up(
2532            allocation
2533                .checked_add(table_end)
2534                .expect("distributed keyframe address overflow"),
2535            DISTRIBUTED_REGION_ALIGN,
2536        )
2537        .expect("distributed keyframe alignment overflow")
2538            - allocation;
2539        for (component, &capacity) in capacities.iter().enumerate() {
2540            let capacity = capacity.max(KEYFRAME_FRAME_HEADER_LEN);
2541            let entry = DISTRIBUTED_TABLE_OFFSET + component * DISTRIBUTED_ENTRY_LEN;
2542            serialized_tasks[entry..entry + 8]
2543                .copy_from_slice(&(region_offset as u64).to_le_bytes());
2544            serialized_tasks[entry + 8..entry + DISTRIBUTED_ENTRY_LEN]
2545                .copy_from_slice(&(capacity as u64).to_le_bytes());
2546            region_offset += align_up(DISTRIBUTED_REGION_LEN + capacity, DISTRIBUTED_REGION_ALIGN)
2547                .expect("distributed keyframe region size overflow");
2548        }
2549        serialized_tasks.truncate(region_offset);
2550        Self {
2551            culistid: 0,
2552            timestamp: CuTime::default(),
2553            serialized_tasks,
2554        }
2555    }
2556
2557    #[doc(hidden)]
2558    pub fn reset_distributed(&mut self, culistid: u64, timestamp: CuTime, interval: u32) {
2559        self.culistid = culistid;
2560        self.timestamp = timestamp;
2561        let active = interval != 0 && culistid.is_multiple_of(u64::from(interval));
2562        if distributed_count(&self.serialized_tasks)
2563            .ok()
2564            .flatten()
2565            .is_some()
2566        {
2567            self.serialized_tasks[DISTRIBUTED_ACTIVE_OFFSET] = u8::from(active);
2568        }
2569        if active {
2570            let count = distributed_count(&self.serialized_tasks)
2571                .expect("distributed keyframe descriptor")
2572                .expect("distributed keyframe storage");
2573            for component in 0..count {
2574                let (offset, _) = distributed_region(&self.serialized_tasks, component)
2575                    .expect("distributed keyframe region");
2576                self.serialized_tasks[offset..offset + DISTRIBUTED_REGION_LEN].fill(0);
2577            }
2578        }
2579    }
2580
2581    #[doc(hidden)]
2582    #[inline]
2583    pub fn discard_distributed(&mut self) {
2584        if distributed_count(&self.serialized_tasks)
2585            .ok()
2586            .flatten()
2587            .is_some()
2588        {
2589            self.serialized_tasks[DISTRIBUTED_ACTIVE_OFFSET] = 0;
2590        }
2591    }
2592
2593    /// Capture one scheduled component into its exclusive region.
2594    ///
2595    /// # Safety
2596    /// `ptr` is null or points to the admitted slot's distributed keyframe
2597    /// allocation of `allocation_len` bytes. Generated execution exclusively
2598    /// owns `component` until the call returns and joins all component writers
2599    /// before output reads the keyframe.
2600    #[doc(hidden)]
2601    pub unsafe fn freeze_region(
2602        ptr: *mut u8,
2603        allocation_len: usize,
2604        component: usize,
2605        task: &impl Freezable,
2606    ) -> CuResult<usize> {
2607        if ptr.is_null() || allocation_len <= DISTRIBUTED_ACTIVE_OFFSET {
2608            return Ok(0);
2609        }
2610        if unsafe { ptr.add(DISTRIBUTED_ACTIVE_OFFSET).read() } == 0 {
2611            return Ok(0);
2612        }
2613        // SAFETY: only the immutable descriptor table is read here. No shared
2614        // reference spanning regions being written by other lanes is formed.
2615        let (offset, capacity) =
2616            unsafe { distributed_region_raw(ptr.cast_const(), allocation_len, component) }
2617                .map_err(CuError::from)?;
2618        // SAFETY: the region bounds were checked above, and this component has
2619        // exclusive access to its length word for the duration of the call.
2620        let length = u64::from_le(unsafe { ptr.add(offset).cast::<u64>().read_unaligned() });
2621        if length != 0 {
2622            return Err(CuError::from(
2623                "Component captured twice in one distributed keyframe",
2624            ));
2625        }
2626        // SAFETY: generated scheduling assigns this disjoint region to exactly
2627        // one component writer until the execution wave joins.
2628        let frame = unsafe {
2629            core::slice::from_raw_parts_mut(ptr.add(offset + DISTRIBUTED_REGION_LEN), capacity)
2630        };
2631        let mut writer = RegionWriter {
2632            bytes: &mut frame[KEYFRAME_FRAME_HEADER_LEN..],
2633            written: 0,
2634        };
2635        let mut encoder = EncoderImpl::new(&mut writer, bincode::config::standard());
2636        BincodeAdapter(task).encode(&mut encoder).map_err(|error| {
2637            CuError::from(format!("Failed to capture keyframe component: {error}"))
2638        })?;
2639        let payload_len = writer.written;
2640        let payload_len = u32::try_from(payload_len)
2641            .map_err(|_| CuError::from("Keyframe component exceeds u32 size"))?;
2642        frame[..KEYFRAME_FRAME_HEADER_LEN].copy_from_slice(&payload_len.to_le_bytes());
2643        let frame_len = KEYFRAME_FRAME_HEADER_LEN + payload_len as usize;
2644        unsafe {
2645            ptr.add(offset)
2646                .cast::<u64>()
2647                .write_unaligned(frame_len as u64);
2648        }
2649        Ok(frame_len)
2650    }
2651
2652    fn new() -> Self {
2653        Self::from_serialized(0, CuTime::default(), KEYFRAME_PAYLOAD_HEADER.to_vec())
2654    }
2655
2656    /// This is to be able to avoid reallocations
2657    fn reset(&mut self, culistid: u64, timestamp: CuTime) {
2658        self.culistid = culistid;
2659        self.timestamp = timestamp;
2660        self.serialized_tasks.clear();
2661        self.serialized_tasks
2662            .extend_from_slice(KEYFRAME_PAYLOAD_HEADER);
2663    }
2664
2665    /// Append one length-framed component snapshot in a single `freeze` pass.
2666    fn add_frozen_task(&mut self, task: &impl Freezable) -> Result<usize, EncodeError> {
2667        let cfg = bincode::config::standard();
2668        let start = self.serialized_tasks.len();
2669        let payload_offset =
2670            start
2671                .checked_add(KEYFRAME_FRAME_HEADER_LEN)
2672                .ok_or(EncodeError::Other(
2673                    "keyframe component frame offset overflow",
2674                ))?;
2675        if payload_offset > self.serialized_tasks.capacity() {
2676            return Err(EncodeError::UnexpectedEnd);
2677        }
2678
2679        self.serialized_tasks.resize(payload_offset, 0);
2680        let length_offset = start;
2681        self.serialized_tasks[length_offset..payload_offset].fill(0);
2682
2683        let mut encoder =
2684            EncoderImpl::<_, _>::new(PreallocatedVecWriter(&mut self.serialized_tasks), cfg);
2685        if let Err(error) = BincodeAdapter(task).encode(&mut encoder) {
2686            self.serialized_tasks.truncate(start);
2687            return Err(error);
2688        }
2689        let payload_len = encoder.into_writer().0.len() - payload_offset;
2690        let payload_len = u32::try_from(payload_len).map_err(|_| {
2691            self.serialized_tasks.truncate(start);
2692            EncodeError::OtherString(
2693                "keyframe component snapshot exceeds the u32 frame limit".to_string(),
2694            )
2695        })?;
2696        self.serialized_tasks
2697            .truncate(payload_offset + payload_len as usize);
2698        self.serialized_tasks[length_offset..payload_offset]
2699            .copy_from_slice(&payload_len.to_le_bytes());
2700        Ok(self.serialized_tasks.len() - start)
2701    }
2702}
2703
2704const KEYFRAME_PAYLOAD_MAGIC: &[u8; 4] = b"CUKF";
2705const KEYFRAME_PAYLOAD_VERSION: u8 = 1;
2706const KEYFRAME_PAYLOAD_HEADER: &[u8; 5] = b"CUKF\x01";
2707const KEYFRAME_FRAME_HEADER_LEN: usize = 4;
2708
2709/// Reader for the versioned component frames inside a [`KeyFrame`].
2710#[doc(hidden)]
2711pub struct KeyFramePayloadReader<'a> {
2712    remaining: &'a [u8],
2713    distributed: Option<(&'a [u8], usize, usize)>,
2714}
2715
2716impl<'a> KeyFramePayloadReader<'a> {
2717    /// Validate a keyframe payload and prepare to consume its component frames.
2718    pub fn new(keyframe: &'a KeyFrame) -> CuResult<Self> {
2719        let payload = keyframe.serialized_tasks.as_slice();
2720        if let Some(count) = distributed_count(payload).map_err(CuError::from)? {
2721            return Ok(Self {
2722                remaining: &[],
2723                distributed: Some((payload, 0, count)),
2724            });
2725        }
2726        if payload.len() < KEYFRAME_PAYLOAD_HEADER.len()
2727            || payload[..KEYFRAME_PAYLOAD_MAGIC.len()] != *KEYFRAME_PAYLOAD_MAGIC
2728        {
2729            return Err(CuError::from(
2730                "Unsupported legacy keyframe payload: expected framed format version 1",
2731            ));
2732        }
2733        let version = payload[KEYFRAME_PAYLOAD_MAGIC.len()];
2734        if version != KEYFRAME_PAYLOAD_VERSION {
2735            return Err(CuError::from(format!(
2736                "Unsupported keyframe payload version {version}; expected {KEYFRAME_PAYLOAD_VERSION}"
2737            )));
2738        }
2739        Ok(Self {
2740            remaining: &payload[KEYFRAME_PAYLOAD_HEADER.len()..],
2741            distributed: None,
2742        })
2743    }
2744
2745    /// Consume the next component frame in generated execution order.
2746    pub fn next_frame(&mut self) -> CuResult<&'a [u8]> {
2747        if let Some((bytes, index, count)) = &mut self.distributed {
2748            if *index >= *count {
2749                return Err(CuError::from("Keyframe ended before next component frame"));
2750            }
2751            let frame = distributed_frame(bytes, *index).map_err(CuError::from)?;
2752            *index += 1;
2753            return Ok(&frame[KEYFRAME_FRAME_HEADER_LEN..]);
2754        }
2755        if self.remaining.len() < KEYFRAME_FRAME_HEADER_LEN {
2756            return Err(CuError::from("Keyframe ended before next component frame"));
2757        }
2758        let payload_len = u32::from_le_bytes(
2759            self.remaining[..KEYFRAME_FRAME_HEADER_LEN]
2760                .try_into()
2761                .map_err(|_| CuError::from("Invalid keyframe component frame length"))?,
2762        ) as usize;
2763        let frame_end = KEYFRAME_FRAME_HEADER_LEN
2764            .checked_add(payload_len)
2765            .ok_or_else(|| CuError::from("Keyframe component frame length overflow"))?;
2766        if frame_end > self.remaining.len() {
2767            return Err(CuError::from("Keyframe component frame is truncated"));
2768        }
2769        let payload = &self.remaining[KEYFRAME_FRAME_HEADER_LEN..frame_end];
2770        self.remaining = &self.remaining[frame_end..];
2771        Ok(payload)
2772    }
2773
2774    /// Reject trailing component frames that the generated restore did not consume.
2775    pub fn finish(self) -> CuResult<()> {
2776        if let Some((_, index, count)) = self.distributed {
2777            return if index == count {
2778                Ok(())
2779            } else {
2780                Err(CuError::from("Keyframe contains trailing component data"))
2781            };
2782        }
2783        if self.remaining.is_empty() {
2784            Ok(())
2785        } else {
2786            Err(CuError::from("Keyframe contains trailing component data"))
2787        }
2788    }
2789}
2790
2791struct FrameSliceReader<'a> {
2792    remaining: &'a [u8],
2793}
2794
2795impl Reader for FrameSliceReader<'_> {
2796    fn read(&mut self, bytes: &mut [u8]) -> Result<(), DecodeError> {
2797        if bytes.len() > self.remaining.len() {
2798            return Err(DecodeError::UnexpectedEnd {
2799                additional: bytes.len() - self.remaining.len(),
2800            });
2801        }
2802        let (read, remaining) = self.remaining.split_at(bytes.len());
2803        bytes.copy_from_slice(read);
2804        self.remaining = remaining;
2805        Ok(())
2806    }
2807
2808    fn peek_read(&mut self, length: usize) -> Option<&[u8]> {
2809        self.remaining.get(..length)
2810    }
2811
2812    fn consume(&mut self, length: usize) {
2813        self.remaining = self.remaining.get(length..).unwrap_or_default();
2814    }
2815}
2816
2817/// Thaw one component from an isolated keyframe frame and require full consumption.
2818#[doc(hidden)]
2819pub fn thaw_keyframe_component(item: &mut impl Freezable, frame: &[u8]) -> CuResult<()> {
2820    let reader = FrameSliceReader { remaining: frame };
2821    let mut decoder = DecoderImpl::new(reader, bincode::config::standard(), ());
2822    item.thaw(&mut decoder)
2823        .map_err(|error| CuError::from(format!("Failed to thaw keyframe component: {error}")))?;
2824    let trailing = decoder.reader().remaining.len();
2825    if trailing != 0 {
2826        return Err(CuError::from(format!(
2827            "Keyframe component snapshot has {} trailing bytes",
2828            trailing
2829        )));
2830    }
2831    Ok(())
2832}
2833
2834/// Identifies where the effective runtime configuration came from.
2835#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
2836pub enum RuntimeLifecycleConfigSource {
2837    ProgrammaticOverride,
2838    ExternalFile,
2839    BundledDefault,
2840}
2841
2842/// Stack and process identification metadata persisted in the runtime lifecycle log.
2843#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
2844pub struct RuntimeLifecycleStackInfo {
2845    pub app_name: String,
2846    pub app_version: String,
2847    pub git_commit: Option<String>,
2848    pub git_dirty: Option<bool>,
2849    pub subsystem_id: Option<String>,
2850    pub subsystem_code: u16,
2851    pub instance_id: u32,
2852}
2853
2854/// Why a successful stop was requested.
2855#[derive(Clone, Copy, Encode, Decode, Debug, PartialEq, Eq)]
2856pub enum RuntimeStopReason {
2857    Requested,
2858    Completed,
2859    Error,
2860    Panic,
2861}
2862
2863/// The lifecycle operation that failed.
2864#[derive(Clone, Copy, Encode, Decode, Debug, PartialEq, Eq)]
2865pub enum RuntimeLifecycleOperation {
2866    Start,
2867    Stop,
2868    Iteration,
2869    Shutdown,
2870}
2871
2872/// Runtime lifecycle events. Identity and configuration live in static metadata and section headers.
2873#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
2874pub enum RuntimeLifecycleEvent {
2875    Instantiated {
2876        config_source: RuntimeLifecycleConfigSource,
2877    },
2878    MissionStarted,
2879    MissionStopped {
2880        reason: RuntimeStopReason,
2881    },
2882    LifecycleFailed {
2883        operation: RuntimeLifecycleOperation,
2884        error: String,
2885    },
2886    Panic {
2887        message: String,
2888        file: Option<String>,
2889        line: Option<u32>,
2890        column: Option<u32>,
2891    },
2892    ShutdownCompleted,
2893    /// Clock-curve correction, recorded outside task processing.
2894    #[cfg(feature = "clock-sync")]
2895    ClockSync(crate::clock_sync::ClockSyncRecord),
2896}
2897
2898impl RuntimeLifecycleEvent {
2899    /// Reads an extended clock record, including in offline tooling.
2900    pub fn clock_sync_record(&self) -> Option<&crate::clock_sync::ClockSyncRecord> {
2901        #[cfg(feature = "clock-sync")]
2902        if let Self::ClockSync(record) = self {
2903            return Some(record);
2904        }
2905        None
2906    }
2907}
2908
2909/// One event record persisted in the `UnifiedLogType::RuntimeLifecycle` section.
2910#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
2911pub struct RuntimeLifecycleRecord {
2912    pub timestamp: CuTime,
2913    pub event: RuntimeLifecycleEvent,
2914}
2915
2916/// Semantic output boundary for runtime lifecycle and manifest records.
2917#[doc(hidden)]
2918pub type RuntimeLifecycleSink = SemanticRecordSink<RuntimeLifecycleRecord>;
2919
2920impl<
2921    CT,
2922    CB,
2923    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
2924    M: CuMonitor,
2925    const NBCL: usize,
2926> CuRuntime<CT, CB, P, M, NBCL>
2927{
2928    /// Records runtime execution progress in the shared probe.
2929    ///
2930    /// This is intentionally lightweight and does not call monitor callbacks.
2931    #[inline]
2932    pub fn record_execution_marker(&self, marker: ExecutionMarker) {
2933        self.execution_probe.record(marker);
2934    }
2935
2936    /// Returns a shared reference to the concrete runtime execution probe.
2937    ///
2938    /// The generated runtime uses this when it needs a uniform
2939    /// `&RuntimeExecutionProbe` view across `std` and `no_std` builds.
2940    #[inline]
2941    pub fn execution_probe_ref(&self) -> &RuntimeExecutionProbe {
2942        #[cfg(feature = "std")]
2943        {
2944            self.execution_probe.as_ref()
2945        }
2946
2947        #[cfg(not(feature = "std"))]
2948        {
2949            &self.execution_probe
2950        }
2951    }
2952}
2953
2954/// Copper tasks can be of 3 types:
2955/// - Source: only producing output messages (usually used for drivers)
2956/// - Regular: processing input messages and producing output messages, more like compute nodes.
2957/// - Sink: only consuming input messages (usually used for actuators)
2958#[derive(Debug, PartialEq, Eq, Clone, Copy)]
2959pub enum CuTaskType {
2960    Source,
2961    Regular,
2962    Sink,
2963}
2964
2965impl From<TaskKind> for CuTaskType {
2966    fn from(value: TaskKind) -> Self {
2967        match value {
2968            TaskKind::Source => CuTaskType::Source,
2969            TaskKind::Regular | TaskKind::Stateless => CuTaskType::Regular,
2970            TaskKind::Sink => CuTaskType::Sink,
2971        }
2972    }
2973}
2974
2975#[derive(Debug, Clone)]
2976pub struct CuOutputPack {
2977    pub culist_index: u32,
2978    pub msg_types: Vec<String>,
2979    /// Per-port source channel, parallel to `msg_types`.
2980    ///
2981    /// `None` for ports that are not a bridge channel. A node may expose
2982    /// several ports sharing the same `msg_type` distinguished only by their
2983    /// bridge channel (e.g. a stereo driver publishing `Image` on `left` and
2984    /// `right`); keying routing on `msg_type` alone would collapse those ports
2985    /// (see #791).
2986    pub src_channels: Vec<Option<String>>,
2987}
2988
2989#[derive(Debug, Clone)]
2990pub struct CuInputMsg {
2991    pub culist_index: u32,
2992    pub msg_type: String,
2993    pub src_port: usize,
2994    pub edge_id: usize,
2995    pub connection_order: usize,
2996}
2997
2998/// Which part of its node's job one plan step runs.
2999///
3000/// This is deliberately not folded into [`CuTaskType`]: that enum encodes the
3001/// graph role (Source/Regular/Sink) and drives call-shape decisions everywhere,
3002/// while the phase is orthogonal — an anytime node stays `Regular` and appears
3003/// as one base step plus `max_refines` refine steps (see
3004/// [`expand_anytime_steps`]).
3005#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3006pub enum CuStepPhase {
3007    /// The whole `process()` of a non-anytime task.
3008    #[default]
3009    Whole,
3010    /// The dead-on-arrival age check plus `base()`, at the node's topological
3011    /// position.
3012    AnytimeBase,
3013    /// Exactly one `refine()` quantum.
3014    AnytimeRefine,
3015}
3016
3017/// This structure represents a step in the execution plan.
3018pub struct CuExecutionStep {
3019    /// NodeId: node id of the task to execute
3020    pub node_id: NodeId,
3021    /// Node: node instance
3022    pub node: Node,
3023    /// CuTaskType: type of the task
3024    pub task_type: CuTaskType,
3025    /// Which part of the node's job this step runs (anytime nodes span several
3026    /// steps; everything else is a single `Whole` step).
3027    pub phase: CuStepPhase,
3028
3029    /// the indices in the copper list of the input messages and their types
3030    /// (empty for anytime refine steps: refinement reads no input)
3031    pub input_msg_indices_types: Vec<CuInputMsg>,
3032
3033    /// the index in the copper list of the output message and its type
3034    /// (an anytime node's refine steps carry the same pack as its base step)
3035    pub output_msg_pack: Option<CuOutputPack>,
3036}
3037
3038impl Debug for CuExecutionStep {
3039    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
3040        f.write_str(format!("   CuExecutionStep: Node Id: {}\n", self.node_id).as_str())?;
3041        f.write_str(format!("                  task_type: {:?}\n", self.node.get_type()).as_str())?;
3042        f.write_str(format!("                       task: {:?}\n", self.task_type).as_str())?;
3043        f.write_str(format!("                      phase: {:?}\n", self.phase).as_str())?;
3044        f.write_str(
3045            format!(
3046                "              input_msg_types: {:?}\n",
3047                self.input_msg_indices_types
3048            )
3049            .as_str(),
3050        )?;
3051        f.write_str(format!("       output_msg_pack: {:?}\n", self.output_msg_pack).as_str())?;
3052        Ok(())
3053    }
3054}
3055
3056/// This structure represents a loop in the execution plan.
3057/// It is used to represent a sequence of Execution units (loop or steps) that are executed
3058/// multiple times.
3059/// if loop_count is None, the loop is infinite.
3060pub struct CuExecutionLoop {
3061    pub steps: Vec<CuExecutionUnit>,
3062    pub loop_count: Option<u32>,
3063}
3064
3065impl Debug for CuExecutionLoop {
3066    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
3067        f.write_str("CuExecutionLoop:\n")?;
3068        for step in &self.steps {
3069            match step {
3070                CuExecutionUnit::Step(step) => {
3071                    step.fmt(f)?;
3072                }
3073                CuExecutionUnit::Loop(l) => {
3074                    l.fmt(f)?;
3075                }
3076            }
3077        }
3078
3079        f.write_str(format!("   count: {:?}", self.loop_count).as_str())?;
3080        Ok(())
3081    }
3082}
3083
3084/// This structure represents a step in the execution plan.
3085#[derive(Debug)]
3086pub enum CuExecutionUnit {
3087    Step(Box<CuExecutionStep>),
3088    Loop(CuExecutionLoop),
3089}
3090
3091pub fn find_task_type_for_id(graph: &CuGraph, node_id: NodeId) -> CuResult<CuTaskType> {
3092    let node = graph
3093        .get_node(node_id)
3094        .ok_or_else(|| CuError::from(format!("Node id {node_id} not found")))?;
3095
3096    if node.get_flavor() == crate::config::Flavor::Task {
3097        return resolve_task_kind_for_id(graph, node_id).map(Into::into);
3098    }
3099
3100    let has_inputs = !graph.get_dst_edges(node_id)?.is_empty();
3101    let has_outputs = !graph.get_src_edges(node_id)?.is_empty();
3102    Ok(match (has_inputs, has_outputs) {
3103        (false, true) => CuTaskType::Source,
3104        (true, false) => CuTaskType::Sink,
3105        _ => CuTaskType::Regular,
3106    })
3107}
3108
3109/// Compute the default serial execution plan for `graph`.
3110pub fn compute_runtime_plan(graph: &CuGraph) -> CuResult<CuExecutionLoop> {
3111    let order = topo_bfs_order(graph)?;
3112    check_order(graph, &order)?;
3113    plan_from_order(graph, &order)
3114}
3115
3116/// Expands every foreground anytime node of an already-computed plan into its
3117/// chunked steps.
3118///
3119/// The node's single `Whole` step becomes an [`CuStepPhase::AnytimeBase`] step
3120/// at its topological position, and `max_refines` [`CuStepPhase::AnytimeRefine`]
3121/// steps (one `refine()` quantum each) are woven between it and the earliest
3122/// step consuming the node's output: one immediately after the base step, one
3123/// after each subsequent independent step, and the remainder contiguously
3124/// before the consumer. If `max_refines` is smaller than the gap, later gap
3125/// steps get no quantum between them; if the node has no consumer in this
3126/// plan, every refine step sits right after the base step.
3127///
3128/// The refine count must be known here — the emission count *is* the iteration
3129/// bound — which is why `max_refines` is mandatory for foreground anytime
3130/// nodes. How many quanta run and where they sit between other steps is
3131/// entirely this compile-time scheduling decision; the generated code carries
3132/// no counter.
3133pub fn expand_anytime_steps(plan: &mut CuExecutionLoop) -> CuResult<()> {
3134    loop {
3135        // One node at a time: expanded steps get a non-`Whole` phase, so the
3136        // scan converges even though insertions shift positions.
3137        let Some(base_pos) = plan.steps.iter().position(|unit| {
3138            matches!(
3139                unit,
3140                CuExecutionUnit::Step(step) if step.phase == CuStepPhase::Whole
3141                    && step.node.anytime().is_some()
3142                    && !step.node.is_background()
3143            )
3144        }) else {
3145            return Ok(());
3146        };
3147
3148        let CuExecutionUnit::Step(base_step) = &mut plan.steps[base_pos] else {
3149            unreachable!("position() only matches steps");
3150        };
3151        let anytime = base_step
3152            .node
3153            .anytime()
3154            .expect("position() only matches anytime nodes");
3155        // Defense in depth for direct API callers: the macro pipeline rejects
3156        // this at configuration time (config.rs validate_anytime_graph).
3157        let Some(max_refines) = anytime.max_refines else {
3158            return Err(CuError::from(format!(
3159                "Task '{}': a foreground anytime task needs anytime.max_refines to expand into a static plan.",
3160                base_step.node.get_id()
3161            )));
3162        };
3163        base_step.phase = CuStepPhase::AnytimeBase;
3164        let output_pack = base_step.output_msg_pack.clone().ok_or_else(|| {
3165            CuError::from(format!(
3166                "Task '{}': an anytime task needs an output to refine.",
3167                base_step.node.get_id()
3168            ))
3169        })?;
3170        let output_index = output_pack.culist_index;
3171        let node_id = base_step.node_id;
3172        let node = base_step.node.clone();
3173        let task_type = base_step.task_type;
3174
3175        let refine_step = || {
3176            CuExecutionUnit::Step(Box::new(CuExecutionStep {
3177                node_id,
3178                node: node.clone(),
3179                task_type,
3180                phase: CuStepPhase::AnytimeRefine,
3181                input_msg_indices_types: Vec::new(),
3182                output_msg_pack: Some(output_pack.clone()),
3183            }))
3184        };
3185
3186        // Earliest step consuming the node's output; refine steps never match
3187        // (their inputs are empty), so already-expanded nodes stay inert here.
3188        let consumer_pos = plan.steps[base_pos + 1..]
3189            .iter()
3190            .position(|unit| {
3191                matches!(
3192                    unit,
3193                    CuExecutionUnit::Step(step) if step
3194                        .input_msg_indices_types
3195                        .iter()
3196                        .any(|input| input.culist_index == output_index)
3197                )
3198            })
3199            .map(|offset| base_pos + 1 + offset)
3200            .unwrap_or(base_pos + 1);
3201
3202        let mut tail = plan.steps.split_off(base_pos + 1);
3203        let suffix = tail.split_off(consumer_pos - base_pos - 1);
3204        let gap = tail;
3205
3206        // max_refines >= 1 is enforced by AnytimeConfig::validate.
3207        let mut remaining = max_refines.max(1);
3208        remaining -= 1;
3209        plan.steps.push(refine_step());
3210        for gap_unit in gap {
3211            plan.steps.push(gap_unit);
3212            if remaining > 0 {
3213                remaining -= 1;
3214                plan.steps.push(refine_step());
3215            }
3216        }
3217        for _ in 0..remaining {
3218            plan.steps.push(refine_step());
3219        }
3220        plan.steps.extend(suffix);
3221    }
3222}
3223
3224//tests
3225#[cfg(test)]
3226mod tests {
3227    use super::*;
3228    use crate::config::Node;
3229    use crate::context::CuContext;
3230    use crate::cutask::CuSinkTask;
3231    use crate::cutask::{CuSrcTask, Freezable};
3232    use crate::monitoring::NoMonitor;
3233    use crate::reflect::Reflect;
3234    use bincode::Encode;
3235    use core::cell::Cell;
3236    use cu29_traits::{ErasedCuStampedData, ErasedCuStampedDataSet, MatchingTasks};
3237    use serde_derive::{Deserialize, Serialize};
3238    #[cfg(all(feature = "std", feature = "async-cl-io"))]
3239    use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
3240    #[cfg(feature = "std")]
3241    use std::sync::{Arc, Mutex};
3242
3243    struct CountingSnapshot<'a> {
3244        calls: &'a Cell<usize>,
3245        value: u32,
3246        fail: bool,
3247    }
3248
3249    impl Freezable for CountingSnapshot<'_> {
3250        fn freeze<E: bincode::enc::Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
3251            self.calls.set(self.calls.get() + 1);
3252            self.value.encode(encoder)?;
3253            if self.fail {
3254                Err(EncodeError::OtherString(
3255                    "intentional freeze failure".to_string(),
3256                ))
3257            } else {
3258                Ok(())
3259            }
3260        }
3261    }
3262
3263    #[derive(Default)]
3264    struct SnapshotValue(u32);
3265
3266    impl Freezable for SnapshotValue {
3267        fn thaw<D: bincode::de::Decoder>(&mut self, decoder: &mut D) -> Result<(), DecodeError> {
3268            self.0 = u32::decode(decoder)?;
3269            Ok(())
3270        }
3271    }
3272
3273    #[test]
3274    fn keyframe_frames_freeze_once_and_roll_back_only_failed_frame() {
3275        let calls = Cell::new(0);
3276        let mut keyframe = KeyFrame::new();
3277        keyframe
3278            .serialized_tasks
3279            .try_reserve_exact(MIN_KEYFRAME_CAPTURE_CAPACITY)
3280            .unwrap();
3281        keyframe.reset(7, CuTime::from_nanos(70));
3282        keyframe
3283            .add_frozen_task(&CountingSnapshot {
3284                calls: &calls,
3285                value: 11,
3286                fail: false,
3287            })
3288            .unwrap();
3289        let committed_len = keyframe.serialized_tasks.len();
3290
3291        let failing = CountingSnapshot {
3292            calls: &calls,
3293            value: 99,
3294            fail: true,
3295        };
3296        assert!(keyframe.add_frozen_task(&failing).is_err());
3297        assert_eq!(keyframe.serialized_tasks.len(), committed_len);
3298
3299        keyframe
3300            .add_frozen_task(&CountingSnapshot {
3301                calls: &calls,
3302                value: 22,
3303                fail: false,
3304            })
3305            .unwrap();
3306        assert_eq!(calls.get(), 3, "each append must call freeze exactly once");
3307        let first_payload_len = bincode::encode_to_vec(11u32, bincode::config::standard())
3308            .unwrap()
3309            .len();
3310        let second_payload_len = bincode::encode_to_vec(22u32, bincode::config::standard())
3311            .unwrap()
3312            .len();
3313        assert_eq!(
3314            keyframe.serialized_tasks.len(),
3315            KEYFRAME_PAYLOAD_HEADER.len()
3316                + 2 * KEYFRAME_FRAME_HEADER_LEN
3317                + first_payload_len
3318                + second_payload_len,
3319            "component frames carry only a length prefix"
3320        );
3321
3322        let mut frames = KeyFramePayloadReader::new(&keyframe).unwrap();
3323        let mut first = SnapshotValue::default();
3324        thaw_keyframe_component(&mut first, frames.next_frame().unwrap()).unwrap();
3325        let mut second = SnapshotValue::default();
3326        thaw_keyframe_component(&mut second, frames.next_frame().unwrap()).unwrap();
3327        frames.finish().unwrap();
3328        assert_eq!((first.0, second.0), (11, 22));
3329    }
3330
3331    #[test]
3332    fn distributed_keyframe_preserves_serial_wire_and_restore_order() {
3333        let serial_calls = Cell::new(0);
3334        let mut serial = KeyFrame::new();
3335        serial.serialized_tasks.reserve(256);
3336        serial.reset(17, CuTime::from_nanos(123));
3337        serial
3338            .add_frozen_task(&CountingSnapshot {
3339                calls: &serial_calls,
3340                value: 11,
3341                fail: false,
3342            })
3343            .unwrap();
3344        serial
3345            .add_frozen_task(&CountingSnapshot {
3346                calls: &serial_calls,
3347                value: 22,
3348                fail: false,
3349            })
3350            .unwrap();
3351
3352        let distributed_calls = Cell::new(0);
3353        let mut distributed = KeyFrame::distributed(&[128, 128]);
3354        distributed.reset_distributed(17, CuTime::from_nanos(123), 1);
3355        for component in 0..2 {
3356            let (offset, _) = distributed_region(&distributed.serialized_tasks, component).unwrap();
3357            assert_eq!(
3358                (distributed.serialized_tasks.as_ptr() as usize + offset)
3359                    % DISTRIBUTED_REGION_ALIGN,
3360                0,
3361            );
3362        }
3363        let capture_ptr = distributed.serialized_tasks.as_mut_ptr();
3364        let capture_len = distributed.serialized_tasks.len();
3365        unsafe {
3366            KeyFrame::freeze_region(
3367                capture_ptr,
3368                capture_len,
3369                1,
3370                &CountingSnapshot {
3371                    calls: &distributed_calls,
3372                    value: 22,
3373                    fail: false,
3374                },
3375            )
3376            .unwrap();
3377            KeyFrame::freeze_region(
3378                capture_ptr,
3379                capture_len,
3380                0,
3381                &CountingSnapshot {
3382                    calls: &distributed_calls,
3383                    value: 11,
3384                    fail: false,
3385                },
3386            )
3387            .unwrap();
3388        }
3389
3390        let config = bincode::config::standard();
3391        assert_eq!(
3392            bincode::encode_to_vec(&distributed, config).unwrap(),
3393            bincode::encode_to_vec(&serial, config).unwrap(),
3394        );
3395        let mut frames = KeyFramePayloadReader::new(&distributed).unwrap();
3396        let mut first = SnapshotValue::default();
3397        thaw_keyframe_component(&mut first, frames.next_frame().unwrap()).unwrap();
3398        let mut second = SnapshotValue::default();
3399        thaw_keyframe_component(&mut second, frames.next_frame().unwrap()).unwrap();
3400        frames.finish().unwrap();
3401        assert_eq!((first.0, second.0), (11, 22));
3402        assert_eq!(distributed_calls.get(), 2);
3403    }
3404
3405    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
3406    #[test]
3407    fn arena_reset_and_distributed_capture_do_not_allocate() {
3408        let calls = Cell::new(0);
3409        let mut slots = allocate_slots::<Msgs>(1);
3410        slots[0].prepare_keyframes(&[128]);
3411        let mut slot = slots.pop().unwrap();
3412
3413        let allocations = crate::monitoring::ScopedAllocCounter::new();
3414        slot.reset(3);
3415        slot.keyframe_mut()
3416            .unwrap()
3417            .reset_distributed(3, CuTime::from_nanos(30), 1);
3418        let (capture_ptr, capture_len) = slot.keyframe_capture();
3419        unsafe {
3420            KeyFrame::freeze_region(
3421                capture_ptr,
3422                capture_len,
3423                0,
3424                &CountingSnapshot {
3425                    calls: &calls,
3426                    value: 42,
3427                    fail: false,
3428                },
3429            )
3430            .unwrap();
3431        }
3432
3433        assert_eq!(allocations.allocated(), 0);
3434        assert_eq!(calls.get(), 1);
3435    }
3436
3437    #[cfg(feature = "std")]
3438    #[test]
3439    fn arena_slots_wrap_without_moving_storage() {
3440        let mut slots = allocate_slots::<Msgs>(2);
3441        let pointers = slots
3442            .iter_mut()
3443            .map(CuSlotLease::as_ptr)
3444            .collect::<Vec<_>>();
3445
3446        for generation in 1..=4 {
3447            slots.rotate_left(1);
3448            for slot in &mut slots {
3449                let index = slot.index();
3450                slot.reset((generation * 2 + index) as u64);
3451                assert_eq!(slot.generation(), generation as u64);
3452                assert_eq!(slot.as_ptr(), pointers[index]);
3453            }
3454        }
3455    }
3456
3457    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
3458    #[test]
3459    fn preallocated_keyframe_append_does_not_allocate() {
3460        let calls = Cell::new(0);
3461        let mut keyframe = KeyFrame::new();
3462        keyframe
3463            .serialized_tasks
3464            .try_reserve_exact(MIN_KEYFRAME_CAPTURE_CAPACITY)
3465            .unwrap();
3466        keyframe.reset(3, CuTime::from_nanos(30));
3467
3468        let allocations = crate::monitoring::ScopedAllocCounter::new();
3469        keyframe
3470            .add_frozen_task(&CountingSnapshot {
3471                calls: &calls,
3472                value: 42,
3473                fail: false,
3474            })
3475            .unwrap();
3476
3477        assert_eq!(allocations.allocated(), 0);
3478        assert_eq!(calls.get(), 1);
3479    }
3480
3481    #[test]
3482    fn keyframe_reader_rejects_legacy_payload_clearly() {
3483        let keyframe = KeyFrame::from_serialized(0, CuTime::default(), vec![0, 1, 2]);
3484        let error = match KeyFramePayloadReader::new(&keyframe) {
3485            Ok(_) => panic!("legacy payload unexpectedly accepted"),
3486            Err(error) => error,
3487        };
3488        assert!(error.to_string().contains("legacy keyframe payload"));
3489    }
3490
3491    #[derive(Reflect)]
3492    pub struct TestSource {}
3493
3494    impl Freezable for TestSource {}
3495
3496    impl CuSrcTask for TestSource {
3497        type Resources<'r> = ();
3498        type Output<'m> = ();
3499        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
3500        where
3501            Self: Sized,
3502        {
3503            Ok(Self {})
3504        }
3505
3506        fn process(&mut self, _ctx: &CuContext, _empty_msg: &mut Self::Output<'_>) -> CuResult<()> {
3507            Ok(())
3508        }
3509    }
3510
3511    #[derive(Reflect)]
3512    pub struct TestSink {}
3513
3514    impl Freezable for TestSink {}
3515
3516    impl CuSinkTask for TestSink {
3517        type Resources<'r> = ();
3518        type Input<'m> = ();
3519
3520        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
3521        where
3522            Self: Sized,
3523        {
3524            Ok(Self {})
3525        }
3526
3527        fn process(&mut self, _ctx: &CuContext, _input: &Self::Input<'_>) -> CuResult<()> {
3528            Ok(())
3529        }
3530    }
3531
3532    // Those should be generated by the derive macro
3533    type Tasks = (TestSource, TestSink);
3534    type TestRuntime = CuRuntime<Tasks, (), Msgs, NoMonitor, 2>;
3535    const TEST_NBCL: usize = 2;
3536
3537    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
3538    struct Msgs(());
3539
3540    impl ErasedCuStampedDataSet for Msgs {
3541        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
3542            Vec::new()
3543        }
3544    }
3545
3546    impl MatchingTasks for Msgs {
3547        fn get_all_task_ids() -> &'static [&'static str] {
3548            &[]
3549        }
3550    }
3551
3552    impl CuListZeroedInit for Msgs {
3553        fn init_zeroed(&mut self) {}
3554    }
3555
3556    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
3557    struct IntMsgs(i32);
3558
3559    impl ErasedCuStampedDataSet for IntMsgs {
3560        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
3561            Vec::new()
3562        }
3563    }
3564
3565    impl MatchingTasks for IntMsgs {
3566        fn get_all_task_ids() -> &'static [&'static str] {
3567            &[]
3568        }
3569    }
3570
3571    impl CuListZeroedInit for IntMsgs {
3572        fn init_zeroed(&mut self) {}
3573    }
3574
3575    #[cfg(not(feature = "async-cl-io"))]
3576    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
3577    struct DistributedIntMsgs(i32);
3578
3579    #[cfg(not(feature = "async-cl-io"))]
3580    impl ErasedCuStampedDataSet for DistributedIntMsgs {
3581        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
3582            Vec::new()
3583        }
3584    }
3585
3586    #[cfg(not(feature = "async-cl-io"))]
3587    impl MatchingTasks for DistributedIntMsgs {
3588        fn get_all_task_ids() -> &'static [&'static str] {
3589            &[]
3590        }
3591    }
3592
3593    #[cfg(not(feature = "async-cl-io"))]
3594    impl CuListZeroedInit for DistributedIntMsgs {
3595        const DISTRIBUTED: bool = true;
3596
3597        fn init_zeroed(&mut self) {}
3598    }
3599
3600    #[cfg(feature = "std")]
3601    fn tasks_instanciator(
3602        all_instances_configs: Vec<Option<&ComponentConfig>>,
3603        _resources: &mut ResourceManager,
3604        _thread_pools: &[Option<Arc<rayon::ThreadPool>>],
3605    ) -> CuResult<Tasks> {
3606        Ok((
3607            TestSource::new(all_instances_configs[0], ())?,
3608            TestSink::new(all_instances_configs[1], ())?,
3609        ))
3610    }
3611
3612    #[cfg(not(feature = "std"))]
3613    fn tasks_instanciator(
3614        all_instances_configs: Vec<Option<&ComponentConfig>>,
3615        _resources: &mut ResourceManager,
3616    ) -> CuResult<Tasks> {
3617        Ok((
3618            TestSource::new(all_instances_configs[0], ())?,
3619            TestSink::new(all_instances_configs[1], ())?,
3620        ))
3621    }
3622
3623    fn monitor_instanciator(
3624        _config: &CuConfig,
3625        metadata: CuMonitoringMetadata,
3626        runtime: CuMonitoringRuntime,
3627    ) -> NoMonitor {
3628        NoMonitor::new(metadata, runtime).expect("NoMonitor::new should never fail")
3629    }
3630
3631    fn bridges_instanciator(_config: &CuConfig, _resources: &mut ResourceManager) -> CuResult<()> {
3632        Ok(())
3633    }
3634
3635    fn resources_instanciator(_config: &CuConfig) -> CuResult<ResourceManager> {
3636        Ok(ResourceManager::new(&[]))
3637    }
3638
3639    #[derive(Debug)]
3640    struct FakeWriter {}
3641
3642    impl<E: Encode> WriteStream<E> for FakeWriter {
3643        fn log(&mut self, _obj: &E) -> CuResult<()> {
3644            Ok(())
3645        }
3646    }
3647
3648    #[cfg(not(feature = "async-cl-io"))]
3649    #[derive(Debug)]
3650    struct RecordingSyncWriter {
3651        ids: Arc<Mutex<Vec<u64>>>,
3652        last_log_bytes: usize,
3653        fail_on: Option<u64>,
3654    }
3655
3656    #[cfg(not(feature = "async-cl-io"))]
3657    impl WriteStream<CopperList<IntMsgs>> for RecordingSyncWriter {
3658        fn log(&mut self, culist: &CopperList<IntMsgs>) -> CuResult<()> {
3659            self.ids.lock().unwrap().push(culist.id);
3660            if self.fail_on == Some(culist.id) {
3661                return Err(CuError::from(format!(
3662                    "logger failed for CopperList #{}",
3663                    culist.id
3664                )));
3665            }
3666            Ok(())
3667        }
3668
3669        fn last_log_bytes(&self) -> Option<usize> {
3670            Some(self.last_log_bytes)
3671        }
3672    }
3673
3674    #[cfg(feature = "std")]
3675    #[derive(Debug)]
3676    struct RecordingSemanticSink {
3677        ids: Arc<Mutex<Vec<u64>>>,
3678    }
3679
3680    #[cfg(feature = "std")]
3681    impl WriteStream<CopperList<IntMsgs>> for RecordingSemanticSink {
3682        fn log(&mut self, culist: &CopperList<IntMsgs>) -> CuResult<()> {
3683            assert_eq!(culist.get_state(), CopperListState::BeingSerialized);
3684            self.ids.lock().unwrap().push(culist.id);
3685            Ok(())
3686        }
3687
3688        fn last_log_bytes(&self) -> Option<usize> {
3689            Some(23)
3690        }
3691    }
3692
3693    #[cfg(feature = "std")]
3694    impl WriteStream<CopperList<Msgs>> for RecordingSemanticSink {
3695        fn log(&mut self, culist: &CopperList<Msgs>) -> CuResult<()> {
3696            assert_eq!(culist.get_state(), CopperListState::BeingSerialized);
3697            self.ids.lock().unwrap().push(culist.id);
3698            Ok(())
3699        }
3700
3701        fn last_log_bytes(&self) -> Option<usize> {
3702            Some(23)
3703        }
3704    }
3705
3706    #[cfg(feature = "std")]
3707    #[derive(Debug)]
3708    struct RecordingKeyFrameSink {
3709        ids: Arc<Mutex<Vec<u64>>>,
3710    }
3711
3712    #[cfg(feature = "std")]
3713    impl WriteStream<KeyFrame> for RecordingKeyFrameSink {
3714        fn log(&mut self, keyframe: &KeyFrame) -> CuResult<()> {
3715            self.ids.lock().unwrap().push(keyframe.culistid);
3716            Ok(())
3717        }
3718
3719        fn last_log_bytes(&self) -> Option<usize> {
3720            Some(29)
3721        }
3722    }
3723
3724    #[test]
3725    fn test_runtime_instantiation() {
3726        let mut config = CuConfig::default();
3727        let graph = config.get_graph_mut(None).unwrap();
3728        graph.add_node(Node::new("a", "TestSource")).unwrap();
3729        graph.add_node(Node::new("b", "TestSink")).unwrap();
3730        graph.connect(0, 1, "()").unwrap();
3731        let runtime: CuResult<TestRuntime> =
3732            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
3733                RobotClock::default(),
3734                &config,
3735                crate::config::DEFAULT_MISSION_ID,
3736                CuRuntimeParts::new(
3737                    tasks_instanciator,
3738                    &[],
3739                    &[],
3740                    #[cfg(feature = "std")]
3741                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
3742                    monitor_instanciator,
3743                    bridges_instanciator,
3744                ),
3745                FakeWriter {},
3746                FakeWriter {},
3747                OutputRequirements::new(true, true),
3748            )
3749            .try_with_resources_instantiator(resources_instanciator)
3750            .and_then(|builder| builder.build());
3751        assert!(runtime.is_ok());
3752    }
3753
3754    #[cfg(feature = "std")]
3755    #[test]
3756    fn downstream_requirements_are_independent_from_local_logging() {
3757        let mut config = CuConfig::default();
3758        config.logging = Some(crate::config::LoggingConfig {
3759            enable_task_logging: false,
3760            enable_keyframe_logging: false,
3761            keyframe_interval: Some(1),
3762            ..Default::default()
3763        });
3764        let graph = config.get_graph_mut(None).unwrap();
3765        graph.add_node(Node::new("a", "TestSource")).unwrap();
3766        graph.add_node(Node::new("b", "TestSink")).unwrap();
3767        graph.connect(0, 1, "()").unwrap();
3768
3769        let copperlist_ids = Arc::new(Mutex::new(Vec::new()));
3770        let keyframe_ids = Arc::new(Mutex::new(Vec::new()));
3771        let mut runtime: TestRuntime =
3772            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
3773                RobotClock::default(),
3774                &config,
3775                crate::config::DEFAULT_MISSION_ID,
3776                CuRuntimeParts::new(
3777                    tasks_instanciator,
3778                    &[],
3779                    &[],
3780                    #[cfg(feature = "std")]
3781                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
3782                    monitor_instanciator,
3783                    bridges_instanciator,
3784                ),
3785                RecordingSemanticSink {
3786                    ids: copperlist_ids.clone(),
3787                },
3788                RecordingKeyFrameSink {
3789                    ids: keyframe_ids.clone(),
3790                },
3791                OutputRequirements::new(true, false).union(OutputRequirements::new(false, true)),
3792            )
3793            .try_with_resources_instantiator(resources_instanciator)
3794            .and_then(|builder| builder.build())
3795            .unwrap();
3796
3797        let copperlist = runtime.copperlists_manager.create().unwrap();
3798        copperlist.change_state(CopperListState::Processing);
3799        runtime.copperlists_manager.end_of_processing(0).unwrap();
3800        runtime.copperlists_manager.finish_pending().unwrap();
3801
3802        runtime
3803            .keyframes_manager
3804            .try_reset(0, &runtime.clock)
3805            .unwrap();
3806        runtime.keyframes_manager.end_of_processing(0).unwrap();
3807        runtime.keyframes_manager.finish_pending().unwrap();
3808
3809        assert_eq!(*copperlist_ids.lock().unwrap(), vec![0]);
3810        assert_eq!(*keyframe_ids.lock().unwrap(), vec![0]);
3811    }
3812
3813    #[test]
3814    fn test_rate_target_period_rejects_zero() {
3815        let err = rate_target_period(0).expect_err("zero rate target should fail");
3816        assert!(
3817            err.to_string()
3818                .contains("Runtime rate target cannot be zero"),
3819            "unexpected error: {err}"
3820        );
3821    }
3822
3823    #[test]
3824    fn test_loop_rate_limiter_advances_to_next_period_when_on_time() {
3825        let (clock, mock) = RobotClock::mock();
3826        let mut limiter = LoopRateLimiter::from_rate_target_hz(100, &clock).unwrap();
3827        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(10_000_000));
3828
3829        mock.set_value(10_000_000);
3830        limiter.mark_tick(&clock);
3831
3832        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(20_000_000));
3833    }
3834
3835    #[test]
3836    fn test_loop_rate_limiter_skips_missed_periods_without_resetting_phase() {
3837        let (clock, mock) = RobotClock::mock();
3838        let mut limiter = LoopRateLimiter::from_rate_target_hz(100, &clock).unwrap();
3839
3840        mock.set_value(35_000_000);
3841        limiter.mark_tick(&clock);
3842
3843        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(40_000_000));
3844    }
3845
3846    #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
3847    #[test]
3848    fn test_loop_rate_limiter_spin_window_is_fixed_scheduler_window() {
3849        let (clock, _) = RobotClock::mock();
3850        let limiter = LoopRateLimiter::from_rate_target_hz(1_000, &clock).unwrap();
3851        assert_eq!(limiter.spin_window(), CuDuration::from(200_000));
3852
3853        let fast = LoopRateLimiter::from_rate_target_hz(10_000, &clock).unwrap();
3854        assert_eq!(fast.spin_window(), CuDuration::from(200_000));
3855    }
3856
3857    #[cfg(not(feature = "async-cl-io"))]
3858    #[test]
3859    fn test_copperlists_manager_lifecycle() {
3860        let mut config = CuConfig::default();
3861        let graph = config.get_graph_mut(None).unwrap();
3862        graph.add_node(Node::new("a", "TestSource")).unwrap();
3863        graph.add_node(Node::new("b", "TestSink")).unwrap();
3864        graph.connect(0, 1, "()").unwrap();
3865
3866        let mut runtime: TestRuntime =
3867            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
3868                RobotClock::default(),
3869                &config,
3870                crate::config::DEFAULT_MISSION_ID,
3871                CuRuntimeParts::new(
3872                    tasks_instanciator,
3873                    &[],
3874                    &[],
3875                    #[cfg(feature = "std")]
3876                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
3877                    monitor_instanciator,
3878                    bridges_instanciator,
3879                ),
3880                FakeWriter {},
3881                FakeWriter {},
3882                OutputRequirements::new(true, true),
3883            )
3884            .try_with_resources_instantiator(resources_instanciator)
3885            .and_then(|builder| builder.build())
3886            .unwrap();
3887
3888        // Now emulates the generated runtime
3889        {
3890            let copperlists = &mut runtime.copperlists_manager;
3891            let culist0 = copperlists
3892                .create()
3893                .expect("Ran out of space for copper lists");
3894            let id = culist0.id;
3895            assert_eq!(id, 0);
3896            culist0.change_state(CopperListState::Processing);
3897            assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
3898        }
3899
3900        {
3901            let copperlists = &mut runtime.copperlists_manager;
3902            let culist1 = copperlists
3903                .create()
3904                .expect("Ran out of space for copper lists");
3905            let id = culist1.id;
3906            assert_eq!(id, 1);
3907            culist1.change_state(CopperListState::Processing);
3908            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
3909        }
3910
3911        {
3912            let copperlists = &mut runtime.copperlists_manager;
3913            let culist2 = copperlists.create();
3914            assert!(culist2.is_err());
3915            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
3916            // Free in order, should let the top of the stack be serialized and freed.
3917            let _ = copperlists.end_of_processing(1);
3918            assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
3919        }
3920
3921        // Readd a CL
3922        {
3923            let copperlists = &mut runtime.copperlists_manager;
3924            let culist2 = copperlists
3925                .create()
3926                .expect("Ran out of space for copper lists");
3927            let id = culist2.id;
3928            assert_eq!(id, 2);
3929            culist2.change_state(CopperListState::Processing);
3930            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
3931            // Free out of order, the #0 first
3932            let _ = copperlists.end_of_processing(0);
3933            // Should not free up the top of the stack
3934            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
3935
3936            // Free up the top of the stack
3937            let _ = copperlists.end_of_processing(2);
3938            // This should free up 2 CLs
3939
3940            assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
3941        }
3942    }
3943
3944    #[cfg(not(feature = "async-cl-io"))]
3945    #[test]
3946    fn test_sync_copperlists_accessors_passthrough_to_inner_manager() {
3947        let mut copperlists = SyncCopperListsManager::<IntMsgs, 2>::new(None).unwrap();
3948
3949        assert_eq!(copperlists.next_cl_id(), 0);
3950        assert_eq!(copperlists.last_cl_id(), 0);
3951        assert!(copperlists.peek().is_none());
3952
3953        {
3954            let culist = copperlists.create().unwrap();
3955            culist.msgs.0 = 11;
3956            assert_eq!(culist.id, 0);
3957            assert_eq!(culist.get_state(), CopperListState::Initialized);
3958        }
3959
3960        assert_eq!(copperlists.next_cl_id(), 1);
3961        assert_eq!(copperlists.last_cl_id(), 0);
3962        let peeked = copperlists.peek().unwrap();
3963        assert_eq!(peeked.id, 0);
3964        assert_eq!(peeked.msgs.0, 11);
3965        assert_eq!(peeked.get_state(), CopperListState::Initialized);
3966    }
3967
3968    #[cfg(not(feature = "async-cl-io"))]
3969    #[test]
3970    fn test_sync_reclaimed_slot_reuse_reinitializes_state_but_preserves_payload_storage() {
3971        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
3972
3973        {
3974            let culist = copperlists.create().unwrap();
3975            culist.msgs.0 = 41;
3976            culist.change_state(CopperListState::Processing);
3977            assert_eq!(culist.id, 0);
3978        }
3979
3980        copperlists.end_of_processing(0).unwrap();
3981        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
3982
3983        let reused = copperlists.create().unwrap();
3984        assert_eq!(reused.id, 1);
3985        assert_eq!(reused.get_state(), CopperListState::Initialized);
3986        assert_eq!(reused.msgs.0, 41);
3987    }
3988
3989    #[cfg(all(not(feature = "async-cl-io"), debug_assertions))]
3990    #[test]
3991    #[should_panic(expected = "sync end_of_processing expected exactly one active CopperList #99")]
3992    fn test_sync_end_of_processing_unknown_id_panics_in_debug() {
3993        let mut copperlists = SyncCopperListsManager::<IntMsgs, 2>::new(None).unwrap();
3994
3995        {
3996            let culist = copperlists.create().unwrap();
3997            culist.msgs.0 = 10;
3998            culist.change_state(CopperListState::Processing);
3999        }
4000        {
4001            let culist = copperlists.create().unwrap();
4002            culist.msgs.0 = 20;
4003            culist.change_state(CopperListState::Processing);
4004        }
4005
4006        let _ = copperlists.end_of_processing(99);
4007    }
4008
4009    #[cfg(all(not(feature = "async-cl-io"), debug_assertions))]
4010    #[test]
4011    #[should_panic(expected = "sync end_of_processing expected CopperList #0 to be Processing")]
4012    fn test_sync_end_of_processing_wrong_state_panics_in_debug() {
4013        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
4014
4015        {
4016            let culist = copperlists.create().unwrap();
4017            culist.msgs.0 = 10;
4018            assert_eq!(culist.get_state(), CopperListState::Initialized);
4019        }
4020
4021        let _ = copperlists.end_of_processing(0);
4022    }
4023
4024    #[cfg(not(feature = "async-cl-io"))]
4025    #[test]
4026    fn test_sync_end_of_processing_serializes_done_suffix_from_newest_to_oldest() {
4027        let ids = Arc::new(Mutex::new(Vec::new()));
4028        let mut copperlists =
4029            SyncCopperListsManager::<IntMsgs, 2>::new(Some(Box::new(RecordingSyncWriter {
4030                ids: ids.clone(),
4031                last_log_bytes: 17,
4032                fail_on: None,
4033            })))
4034            .unwrap();
4035
4036        {
4037            let culist = copperlists.create().unwrap();
4038            culist.msgs.0 = 10;
4039            culist.change_state(CopperListState::Processing);
4040        }
4041        {
4042            let culist = copperlists.create().unwrap();
4043            culist.msgs.0 = 20;
4044            culist.change_state(CopperListState::Processing);
4045        }
4046
4047        copperlists.end_of_processing(0).unwrap();
4048        assert!(ids.lock().unwrap().is_empty());
4049        assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
4050
4051        copperlists.end_of_processing(1).unwrap();
4052
4053        assert_eq!(*ids.lock().unwrap(), vec![1, 0]);
4054        assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
4055    }
4056
4057    #[cfg(not(feature = "async-cl-io"))]
4058    #[test]
4059    fn test_sync_end_of_processing_updates_logger_counters_on_success() {
4060        let ids = Arc::new(Mutex::new(Vec::new()));
4061        let mut copperlists =
4062            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSyncWriter {
4063                ids: ids.clone(),
4064                last_log_bytes: 17,
4065                fail_on: None,
4066            })))
4067            .unwrap();
4068        let io_cache = crate::monitoring::CuMsgIoCache::<1>::default();
4069
4070        {
4071            let culist = copperlists.create().unwrap();
4072            culist.msgs.0 = 10;
4073            culist.change_state(CopperListState::Processing);
4074        }
4075
4076        {
4077            let capture = crate::monitoring::start_copperlist_io_capture(&io_cache);
4078            capture.select_slot(0);
4079            crate::monitoring::record_payload_handle_bytes(32);
4080        }
4081
4082        copperlists.end_of_processing(0).unwrap();
4083
4084        assert_eq!(*ids.lock().unwrap(), vec![0]);
4085        assert_eq!(copperlists.last_encoded_bytes, 17);
4086        assert_eq!(copperlists.last_handle_bytes, 32);
4087        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
4088    }
4089
4090    #[cfg(feature = "std")]
4091    #[test]
4092    fn test_sync_manager_accepts_nonserializing_semantic_sink() {
4093        let ids = Arc::new(Mutex::new(Vec::new()));
4094        let mut copperlists =
4095            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSemanticSink {
4096                ids: ids.clone(),
4097            })))
4098            .unwrap();
4099
4100        let culist = copperlists.create().unwrap();
4101        culist.change_state(CopperListState::Processing);
4102        copperlists.end_of_processing(0).unwrap();
4103
4104        assert_eq!(*ids.lock().unwrap(), vec![0]);
4105        assert_eq!(copperlists.last_encoded_bytes, 23);
4106        assert_eq!(copperlists.last_handle_bytes, 0);
4107    }
4108
4109    #[cfg(feature = "std")]
4110    #[test]
4111    fn test_keyframe_manager_accepts_nonserializing_semantic_sink() {
4112        let ids = Arc::new(Mutex::new(Vec::new()));
4113        let mut keyframes = KeyFramesManager::new(
4114            Some(Box::new(RecordingKeyFrameSink { ids: ids.clone() })),
4115            1,
4116        )
4117        .unwrap();
4118
4119        keyframes.try_reset(7, &RobotClock::default()).unwrap();
4120        keyframes.end_of_processing(7).unwrap();
4121        keyframes.finish_pending().unwrap();
4122
4123        assert_eq!(*ids.lock().unwrap(), vec![7]);
4124        assert_eq!(keyframes.last_encoded_bytes, 29);
4125    }
4126
4127    #[cfg(not(feature = "async-cl-io"))]
4128    #[test]
4129    fn test_sync_end_of_processing_preserves_slot_on_logger_error() {
4130        let ids = Arc::new(Mutex::new(Vec::new()));
4131        let mut copperlists =
4132            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSyncWriter {
4133                ids: ids.clone(),
4134                last_log_bytes: 17,
4135                fail_on: Some(0),
4136            })))
4137            .unwrap();
4138
4139        {
4140            let culist = copperlists.create().unwrap();
4141            culist.change_state(CopperListState::Processing);
4142        }
4143
4144        let err = copperlists.end_of_processing(0).unwrap_err();
4145
4146        assert!(
4147            err.to_string().contains("logger failed for CopperList #0"),
4148            "unexpected error: {err}"
4149        );
4150        assert_eq!(*ids.lock().unwrap(), vec![0]);
4151        assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
4152        assert_eq!(copperlists.last_encoded_bytes, 0);
4153        assert_eq!(copperlists.last_handle_bytes, 0);
4154
4155        let peeked = copperlists.peek().unwrap();
4156        assert_eq!(peeked.id, 0);
4157        assert_eq!(peeked.get_state(), CopperListState::BeingSerialized);
4158    }
4159
4160    #[cfg(all(not(feature = "async-cl-io"), feature = "std", debug_assertions))]
4161    #[test]
4162    #[should_panic(expected = "sync slot completion expected CopperList #7 to be Processing")]
4163    fn test_sync_submit_slot_wrong_state_panics_in_debug() {
4164        let mut copperlists = SyncCopperListsManager::<DistributedIntMsgs, 1>::new(None).unwrap();
4165        let mut culist = copperlists.acquire_iteration_slot().unwrap();
4166        culist.reset(7);
4167
4168        let _ = copperlists.submit_slot(culist);
4169    }
4170
4171    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4172    #[derive(Debug, Default)]
4173    struct RecordingWriter {
4174        ids: Arc<Mutex<Vec<u64>>>,
4175    }
4176
4177    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4178    impl WriteStream<CopperList<Msgs>> for RecordingWriter {
4179        fn log(&mut self, culist: &CopperList<Msgs>) -> CuResult<()> {
4180            assert_eq!(culist.get_state(), CopperListState::BeingSerialized);
4181            self.ids.lock().unwrap().push(culist.id);
4182            std::thread::sleep(std::time::Duration::from_millis(2));
4183            Ok(())
4184        }
4185    }
4186
4187    #[cfg(feature = "std")]
4188    #[derive(Debug)]
4189    struct OrderedCopperListSink {
4190        events: Arc<Mutex<Vec<&'static str>>>,
4191    }
4192
4193    #[cfg(feature = "std")]
4194    impl WriteStream<CopperList<Msgs>> for OrderedCopperListSink {
4195        fn log(&mut self, _culist: &CopperList<Msgs>) -> CuResult<()> {
4196            self.events.lock().unwrap().push("copperlist");
4197            Ok(())
4198        }
4199    }
4200
4201    #[cfg(feature = "std")]
4202    #[derive(Debug)]
4203    struct OrderedKeyFrameSink {
4204        events: Arc<Mutex<Vec<&'static str>>>,
4205    }
4206
4207    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4208    #[derive(Debug)]
4209    struct BlockingKeyFrameSink {
4210        events: Arc<Mutex<Vec<&'static str>>>,
4211        started: SyncSender<()>,
4212        release: Arc<Mutex<Receiver<()>>>,
4213    }
4214
4215    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4216    impl WriteStream<KeyFrame> for BlockingKeyFrameSink {
4217        fn log(&mut self, _keyframe: &KeyFrame) -> CuResult<()> {
4218            self.events.lock().unwrap().push("keyframe");
4219            self.started
4220                .send(())
4221                .map_err(|_| CuError::from("failed to signal keyframe writer start"))?;
4222            self.release
4223                .lock()
4224                .unwrap()
4225                .recv()
4226                .map_err(|_| CuError::from("failed to release keyframe writer"))
4227        }
4228    }
4229
4230    #[cfg(feature = "std")]
4231    impl WriteStream<KeyFrame> for OrderedKeyFrameSink {
4232        fn log(&mut self, _keyframe: &KeyFrame) -> CuResult<()> {
4233            self.events.lock().unwrap().push("keyframe");
4234            Ok(())
4235        }
4236    }
4237
4238    #[cfg(feature = "std")]
4239    #[test]
4240    fn slot_output_writes_keyframe_then_copperlist_before_recycling() {
4241        let events = Arc::new(Mutex::new(Vec::new()));
4242        let mut manager = SyncCopperListsManager::<Msgs, 1>::new_with_keyframes(
4243            Some(Box::new(OrderedCopperListSink {
4244                events: Arc::clone(&events),
4245            })),
4246            Some(Box::new(OrderedKeyFrameSink {
4247                events: Arc::clone(&events),
4248            })),
4249        )
4250        .unwrap();
4251        let mut slot = allocate_slots::<Msgs>(1).pop().unwrap();
4252        slot.prepare_keyframes(&[128]);
4253        slot.reset(0);
4254        slot.keyframe_mut()
4255            .unwrap()
4256            .reset_distributed(0, CuTime::default(), 1);
4257        let (capture_ptr, capture_len) = slot.keyframe_capture();
4258        unsafe {
4259            KeyFrame::freeze_region(capture_ptr, capture_len, 0, &SnapshotValue(42)).unwrap();
4260        }
4261        slot.change_state(CopperListState::Processing);
4262        let index = slot.index();
4263        let generation = slot.generation();
4264
4265        let recycled = match manager.submit_slot(slot).unwrap() {
4266            OwnedCopperListSubmission::Recycled(slot) => slot,
4267            OwnedCopperListSubmission::Pending => panic!("sync output retained slot"),
4268        };
4269
4270        assert_eq!(*events.lock().unwrap(), vec!["keyframe", "copperlist"]);
4271        assert_eq!(recycled.index(), index);
4272        assert_eq!(recycled.generation(), generation);
4273        assert_eq!(recycled.get_state(), CopperListState::Free);
4274    }
4275
4276    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4277    #[test]
4278    fn async_slot_is_reclaimed_only_after_keyframe_and_copperlist_output() {
4279        let events = Arc::new(Mutex::new(Vec::new()));
4280        let (started_tx, started_rx) = sync_channel(1);
4281        let (release_tx, release_rx) = sync_channel(1);
4282        let mut manager = AsyncCopperListsManager::<Msgs, 2>::new_with_keyframes(
4283            Some(Box::new(OrderedCopperListSink {
4284                events: Arc::clone(&events),
4285            })),
4286            Some(Box::new(BlockingKeyFrameSink {
4287                events: Arc::clone(&events),
4288                started: started_tx,
4289                release: Arc::new(Mutex::new(release_rx)),
4290            })),
4291        )
4292        .unwrap();
4293        manager.prepare_keyframes(&[128]);
4294        let mut slot = manager.acquire_iteration_slot().unwrap();
4295        slot.reset(0);
4296        slot.keyframe_mut()
4297            .unwrap()
4298            .reset_distributed(0, CuTime::default(), 1);
4299        let (capture_ptr, capture_len) = slot.keyframe_capture();
4300        unsafe {
4301            KeyFrame::freeze_region(capture_ptr, capture_len, 0, &SnapshotValue(42)).unwrap();
4302        }
4303        slot.change_state(CopperListState::Processing);
4304        let index = slot.index();
4305        let generation = slot.generation();
4306
4307        assert!(matches!(
4308            manager.submit_slot(slot).unwrap(),
4309            OwnedCopperListSubmission::Pending
4310        ));
4311        started_rx
4312            .recv_timeout(std::time::Duration::from_secs(1))
4313            .unwrap();
4314        assert!(manager.try_reclaim_slot().unwrap().is_none());
4315        assert_eq!(*events.lock().unwrap(), vec!["keyframe"]);
4316
4317        release_tx.send(()).unwrap();
4318        let recycled = manager.wait_reclaim_slot().unwrap();
4319        assert_eq!(*events.lock().unwrap(), vec!["keyframe", "copperlist"]);
4320        assert_eq!(recycled.index(), index);
4321        assert_eq!(recycled.generation(), generation);
4322        assert_eq!(recycled.get_state(), CopperListState::Free);
4323    }
4324
4325    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4326    #[derive(Debug)]
4327    struct BlockingWriter {
4328        ids: Arc<Mutex<Vec<u64>>>,
4329        started: SyncSender<()>,
4330        release: Arc<Mutex<Receiver<()>>>,
4331    }
4332
4333    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4334    impl WriteStream<CopperList<Msgs>> for BlockingWriter {
4335        fn log(&mut self, culist: &CopperList<Msgs>) -> CuResult<()> {
4336            self.ids.lock().unwrap().push(culist.id);
4337            self.started
4338                .send(())
4339                .map_err(|_| CuError::from("failed to signal blocking writer start"))?;
4340            self.release
4341                .lock()
4342                .unwrap()
4343                .recv()
4344                .map_err(|_| CuError::from("failed to release blocking writer"))
4345        }
4346    }
4347
4348    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4349    #[derive(Debug)]
4350    struct BlockingKeyFrameWriter {
4351        ids: Arc<Mutex<Vec<u64>>>,
4352        started: SyncSender<()>,
4353        release: Arc<Mutex<Receiver<()>>>,
4354    }
4355
4356    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4357    impl WriteStream<KeyFrame> for BlockingKeyFrameWriter {
4358        fn log(&mut self, keyframe: &KeyFrame) -> CuResult<()> {
4359            self.ids.lock().unwrap().push(keyframe.culistid);
4360            self.started
4361                .send(())
4362                .map_err(|_| CuError::from("failed to signal blocking keyframe writer start"))?;
4363            self.release
4364                .lock()
4365                .unwrap()
4366                .recv()
4367                .map_err(|_| CuError::from("failed to release blocking keyframe writer"))
4368        }
4369    }
4370
4371    #[test]
4372    fn disabled_keyframes_allocate_no_capture_buffers() {
4373        let keyframes = KeyFramesManager::new(None, 1).unwrap();
4374
4375        assert!(keyframes.inner.is_none());
4376        assert!(!keyframes.captures_keyframe(0));
4377        #[cfg(all(feature = "std", feature = "async-cl-io"))]
4378        {
4379            assert!(keyframes.spares.is_empty());
4380            assert!(keyframes.pending_producer.is_none());
4381            assert!(keyframes.worker_handle.is_none());
4382        }
4383    }
4384
4385    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
4386    #[test]
4387    fn disabled_keyframe_manager_allocates_nothing() {
4388        let allocations = crate::monitoring::ScopedAllocCounter::new();
4389        let keyframes = KeyFramesManager::new(None, 1).unwrap();
4390
4391        assert_eq!(allocations.allocated(), 0);
4392        assert!(keyframes.inner.is_none());
4393    }
4394
4395    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4396    #[test]
4397    fn saturated_keyframe_handoff_skips_capture_before_freezing() {
4398        let ids = Arc::new(Mutex::new(Vec::new()));
4399        let (started_tx, started_rx) = sync_channel(1);
4400        let (release_tx, release_rx) = sync_channel(1);
4401        let mut keyframes = KeyFramesManager::new(
4402            Some(Box::new(BlockingKeyFrameWriter {
4403                ids: ids.clone(),
4404                started: started_tx,
4405                release: Arc::new(Mutex::new(release_rx)),
4406            })),
4407            1,
4408        )
4409        .unwrap();
4410        let freeze_calls = Cell::new(0);
4411        let snapshot = CountingSnapshot {
4412            calls: &freeze_calls,
4413            value: 42,
4414            fail: false,
4415        };
4416        keyframes.begin_capture_preallocation();
4417        keyframes.include_capture_capacity(&snapshot).unwrap();
4418        keyframes.finish_capture_preallocation().unwrap();
4419        freeze_calls.set(0);
4420
4421        keyframes.try_reset(0, &RobotClock::default()).unwrap();
4422        assert_ne!(keyframes.freeze_task(0, &snapshot).unwrap(), 0);
4423        keyframes.end_of_processing(0).unwrap();
4424        started_rx
4425            .recv_timeout(std::time::Duration::from_secs(1))
4426            .unwrap();
4427
4428        keyframes.try_reset(1, &RobotClock::default()).unwrap();
4429        assert_ne!(keyframes.freeze_task(1, &snapshot).unwrap(), 0);
4430        keyframes.end_of_processing(1).unwrap();
4431
4432        keyframes.try_reset(2, &RobotClock::default()).unwrap();
4433        assert_eq!(keyframes.freeze_task(2, &snapshot).unwrap(), 0);
4434        keyframes.end_of_processing(2).unwrap();
4435
4436        assert_eq!(freeze_calls.get(), 2);
4437        assert_eq!(keyframes.dropped_keyframes_total(), 1);
4438        assert_eq!(*ids.lock().unwrap(), vec![0]);
4439
4440        release_tx.send(()).unwrap();
4441        started_rx
4442            .recv_timeout(std::time::Duration::from_secs(1))
4443            .unwrap();
4444        release_tx.send(()).unwrap();
4445        keyframes.finish_pending().unwrap();
4446        assert_eq!(*ids.lock().unwrap(), vec![0, 1]);
4447    }
4448
4449    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4450    #[test]
4451    fn test_async_copperlists_manager_flushes_in_order() {
4452        let ids = Arc::new(Mutex::new(Vec::new()));
4453        let mut copperlists = CopperListsManager::<Msgs, 5>::new(Some(Box::new(RecordingWriter {
4454            ids: ids.clone(),
4455        })))
4456        .unwrap();
4457
4458        for expected_id in 0..4 {
4459            let culist = copperlists.create().unwrap();
4460            assert_eq!(culist.id, expected_id);
4461            culist.change_state(CopperListState::Processing);
4462            copperlists.end_of_processing(expected_id).unwrap();
4463        }
4464
4465        copperlists.finish_pending().unwrap();
4466        assert_eq!(copperlists.available_copper_lists().unwrap(), 5);
4467        assert_eq!(*ids.lock().unwrap(), vec![0, 1, 2, 3]);
4468        assert_eq!(copperlists.dropped_copperlists_total(), 0);
4469    }
4470
4471    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4472    #[test]
4473    fn test_async_handoff_drops_without_exhausting_execution_slot() {
4474        let ids = Arc::new(Mutex::new(Vec::new()));
4475        let (started_tx, started_rx) = sync_channel(1);
4476        let (release_tx, release_rx) = sync_channel(1);
4477        let mut copperlists = CopperListsManager::<Msgs, 2>::new(Some(Box::new(BlockingWriter {
4478            ids: ids.clone(),
4479            started: started_tx,
4480            release: Arc::new(Mutex::new(release_rx)),
4481        })))
4482        .unwrap();
4483
4484        let first = copperlists.create().unwrap();
4485        first.change_state(CopperListState::Processing);
4486        copperlists.end_of_processing(0).unwrap();
4487        started_rx
4488            .recv_timeout(std::time::Duration::from_secs(1))
4489            .unwrap();
4490
4491        let second = copperlists.create().unwrap();
4492        second.change_state(CopperListState::Processing);
4493        copperlists.end_of_processing(1).unwrap();
4494
4495        assert_eq!(copperlists.dropped_copperlists_total(), 1);
4496        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
4497        assert_eq!(*ids.lock().unwrap(), vec![0]);
4498
4499        release_tx.send(()).unwrap();
4500        copperlists.finish_pending().unwrap();
4501        assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
4502    }
4503
4504    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4505    #[test]
4506    fn test_async_slot_handoff_returns_dropped_copperlist_to_caller() {
4507        let ids = Arc::new(Mutex::new(Vec::new()));
4508        let (started_tx, started_rx) = sync_channel(1);
4509        let (release_tx, release_rx) = sync_channel(1);
4510        let mut copperlists = CopperListsManager::<Msgs, 2>::new(Some(Box::new(BlockingWriter {
4511            ids: ids.clone(),
4512            started: started_tx,
4513            release: Arc::new(Mutex::new(release_rx)),
4514        })))
4515        .unwrap();
4516
4517        let mut first = copperlists.acquire_iteration_slot().unwrap();
4518        first.reset(0);
4519        first.change_state(CopperListState::Processing);
4520        assert!(matches!(
4521            copperlists.submit_slot(first).unwrap(),
4522            OwnedCopperListSubmission::Pending
4523        ));
4524        started_rx
4525            .recv_timeout(std::time::Duration::from_secs(1))
4526            .unwrap();
4527
4528        let mut second = copperlists.acquire_iteration_slot().unwrap();
4529        second.reset(1);
4530        second.change_state(CopperListState::Processing);
4531        let recycled = match copperlists.submit_slot(second).unwrap() {
4532            OwnedCopperListSubmission::Recycled(culist) => culist,
4533            OwnedCopperListSubmission::Pending => panic!("saturated handoff accepted CopperList"),
4534        };
4535
4536        assert_eq!(recycled.id, 1);
4537        assert_eq!(recycled.get_state(), CopperListState::Free);
4538        assert_eq!(copperlists.dropped_copperlists_total(), 1);
4539        assert_eq!(*ids.lock().unwrap(), vec![0]);
4540
4541        release_tx.send(()).unwrap();
4542        let completed = copperlists.finish_pending_slots().unwrap();
4543        assert_eq!(completed.len(), 1);
4544        assert_eq!(completed[0].id, 0);
4545    }
4546
4547    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4548    #[test]
4549    fn test_async_output_requires_a_spare_execution_slot() {
4550        let error =
4551            match CopperListsManager::<Msgs, 1>::new(Some(Box::new(RecordingWriter::default()))) {
4552                Ok(_) => panic!("async output unexpectedly accepted a single CopperList slot"),
4553                Err(error) => error,
4554            };
4555
4556        assert!(error.to_string().contains("at least two CopperList slots"));
4557    }
4558
4559    #[cfg(all(feature = "std", feature = "async-cl-io"))]
4560    #[test]
4561    fn test_async_create_reinitializes_reclaimed_slot_state_but_preserves_payload_storage() {
4562        let mut copperlists = CopperListsManager::<IntMsgs, 1>::new(None).unwrap();
4563
4564        {
4565            let culist = copperlists.create().unwrap();
4566            assert_eq!(culist.id, 0);
4567            assert_eq!(culist.get_state(), CopperListState::Initialized);
4568            culist.msgs.0 = 41;
4569            culist.change_state(CopperListState::Processing);
4570        }
4571
4572        copperlists.end_of_processing(0).unwrap();
4573        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
4574
4575        let reused = copperlists.create().unwrap();
4576        assert_eq!(reused.id, 1);
4577        assert_eq!(reused.get_state(), CopperListState::Initialized);
4578        assert_eq!(reused.msgs.0, 41);
4579    }
4580
4581    #[cfg(all(feature = "std", feature = "async-cl-io", debug_assertions))]
4582    #[test]
4583    #[should_panic(expected = "async end_of_processing expected CopperList #0 to be Processing")]
4584    fn test_async_end_of_processing_wrong_state_panics_in_debug() {
4585        let mut copperlists = CopperListsManager::<IntMsgs, 1>::new(None).unwrap();
4586
4587        let culist = copperlists.create().unwrap();
4588        assert_eq!(culist.id, 0);
4589        assert_eq!(culist.get_state(), CopperListState::Initialized);
4590
4591        let _ = copperlists.end_of_processing(0);
4592    }
4593
4594    #[test]
4595    fn test_runtime_task_input_order() {
4596        let mut config = CuConfig::default();
4597        let graph = config.get_graph_mut(None).unwrap();
4598        let src1_id = graph.add_node(Node::new("a", "Source1")).unwrap();
4599        let src2_id = graph.add_node(Node::new("b", "Source2")).unwrap();
4600        let sink_id = graph.add_node(Node::new("c", "Sink")).unwrap();
4601
4602        assert_eq!(src1_id, 0);
4603        assert_eq!(src2_id, 1);
4604
4605        // note that the source2 connection is before the source1
4606        let src1_type = "src1_type";
4607        let src2_type = "src2_type";
4608        graph.connect(src2_id, sink_id, src2_type).unwrap();
4609        graph.connect(src1_id, sink_id, src1_type).unwrap();
4610
4611        let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
4612        let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
4613        // the edge id depends on the order the connection is created, not
4614        // on the node id, and that is what determines the input order
4615        assert_eq!(src1_edge_id, 1);
4616        assert_eq!(src2_edge_id, 0);
4617
4618        let runtime = compute_runtime_plan(graph).unwrap();
4619        let sink_step = runtime
4620            .steps
4621            .iter()
4622            .find_map(|step| match step {
4623                CuExecutionUnit::Step(step) if step.node_id == sink_id => Some(step),
4624                _ => None,
4625            })
4626            .unwrap();
4627
4628        // since the src2 connection was added before src1 connection, the src2 type should be
4629        // first
4630        assert_eq!(sink_step.input_msg_indices_types[0].msg_type, src2_type);
4631        assert_eq!(sink_step.input_msg_indices_types[1].msg_type, src1_type);
4632    }
4633
4634    #[test]
4635    fn test_runtime_output_ports_unique_ordered() {
4636        let mut config = CuConfig::default();
4637        let graph = config.get_graph_mut(None).unwrap();
4638        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
4639        let dst_a_id = graph.add_node(Node::new("dst_a", "SinkA")).unwrap();
4640        let dst_b_id = graph.add_node(Node::new("dst_b", "SinkB")).unwrap();
4641        let dst_a2_id = graph.add_node(Node::new("dst_a2", "SinkA2")).unwrap();
4642        let dst_c_id = graph.add_node(Node::new("dst_c", "SinkC")).unwrap();
4643
4644        graph.connect(src_id, dst_a_id, "msg::A").unwrap();
4645        graph.connect(src_id, dst_b_id, "msg::B").unwrap();
4646        graph.connect(src_id, dst_a2_id, "msg::A").unwrap();
4647        graph.connect(src_id, dst_c_id, "msg::C").unwrap();
4648
4649        let runtime = compute_runtime_plan(graph).unwrap();
4650        let src_step = runtime
4651            .steps
4652            .iter()
4653            .find_map(|step| match step {
4654                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4655                _ => None,
4656            })
4657            .unwrap();
4658
4659        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4660        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B", "msg::C"]);
4661
4662        let dst_a_step = runtime
4663            .steps
4664            .iter()
4665            .find_map(|step| match step {
4666                CuExecutionUnit::Step(step) if step.node_id == dst_a_id => Some(step),
4667                _ => None,
4668            })
4669            .unwrap();
4670        let dst_b_step = runtime
4671            .steps
4672            .iter()
4673            .find_map(|step| match step {
4674                CuExecutionUnit::Step(step) if step.node_id == dst_b_id => Some(step),
4675                _ => None,
4676            })
4677            .unwrap();
4678        let dst_a2_step = runtime
4679            .steps
4680            .iter()
4681            .find_map(|step| match step {
4682                CuExecutionUnit::Step(step) if step.node_id == dst_a2_id => Some(step),
4683                _ => None,
4684            })
4685            .unwrap();
4686        let dst_c_step = runtime
4687            .steps
4688            .iter()
4689            .find_map(|step| match step {
4690                CuExecutionUnit::Step(step) if step.node_id == dst_c_id => Some(step),
4691                _ => None,
4692            })
4693            .unwrap();
4694
4695        assert_eq!(dst_a_step.input_msg_indices_types[0].src_port, 0);
4696        assert_eq!(dst_b_step.input_msg_indices_types[0].src_port, 1);
4697        assert_eq!(dst_a2_step.input_msg_indices_types[0].src_port, 0);
4698        assert_eq!(dst_c_step.input_msg_indices_types[0].src_port, 2);
4699    }
4700
4701    #[test]
4702    fn test_runtime_plan_distinguishes_channel_distinct_outputs() {
4703        let mut config = CuConfig::default();
4704        let graph = config.get_graph_mut(None).unwrap();
4705        let src_id = graph.add_node(Node::new("cam", "Cam")).unwrap();
4706        let sink_id = graph.add_node(Node::new("sink", "Sink")).unwrap();
4707
4708        // Two outputs sharing the same message type, distinguished only by
4709        // their bridge channel (e.g. a stereo camera publishing `Image` on
4710        // `left` and `right`). Regression test for #791.
4711        graph
4712            .connect_ext(
4713                src_id,
4714                sink_id,
4715                "msg::Image",
4716                None,
4717                Some("left".to_string()),
4718                None,
4719            )
4720            .unwrap();
4721        graph
4722            .connect_ext(
4723                src_id,
4724                sink_id,
4725                "msg::Image",
4726                None,
4727                Some("right".to_string()),
4728                None,
4729            )
4730            .unwrap();
4731
4732        let runtime = compute_runtime_plan(graph).unwrap();
4733
4734        let src_step = runtime
4735            .steps
4736            .iter()
4737            .find_map(|step| match step {
4738                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4739                _ => None,
4740            })
4741            .unwrap();
4742        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4743        assert_eq!(output_pack.msg_types, vec!["msg::Image", "msg::Image"]);
4744        assert_eq!(
4745            output_pack.src_channels,
4746            vec![Some("left".to_string()), Some("right".to_string())]
4747        );
4748
4749        let sink_step = runtime
4750            .steps
4751            .iter()
4752            .find_map(|step| match step {
4753                CuExecutionUnit::Step(step) if step.node_id == sink_id => Some(step),
4754                _ => None,
4755            })
4756            .unwrap();
4757
4758        assert_eq!(sink_step.input_msg_indices_types.len(), 2);
4759        let ports: Vec<usize> = sink_step
4760            .input_msg_indices_types
4761            .iter()
4762            .map(|input| input.src_port)
4763            .collect();
4764        assert_eq!(ports, vec![0, 1]);
4765    }
4766
4767    #[test]
4768    fn test_runtime_output_ports_fanout_single() {
4769        let mut config = CuConfig::default();
4770        let graph = config.get_graph_mut(None).unwrap();
4771        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
4772        let dst_a_id = graph.add_node(Node::new("dst_a", "SinkA")).unwrap();
4773        let dst_b_id = graph.add_node(Node::new("dst_b", "SinkB")).unwrap();
4774
4775        graph.connect(src_id, dst_a_id, "i32").unwrap();
4776        graph.connect(src_id, dst_b_id, "i32").unwrap();
4777
4778        let runtime = compute_runtime_plan(graph).unwrap();
4779        let src_step = runtime
4780            .steps
4781            .iter()
4782            .find_map(|step| match step {
4783                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4784                _ => None,
4785            })
4786            .unwrap();
4787
4788        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4789        assert_eq!(output_pack.msg_types, vec!["i32"]);
4790    }
4791
4792    #[test]
4793    fn test_runtime_output_ports_include_nc_outputs() {
4794        let mut config = CuConfig::default();
4795        let graph = config.get_graph_mut(None).unwrap();
4796        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
4797        let dst_id = graph.add_node(Node::new("dst", "Sink")).unwrap();
4798        graph.connect(src_id, dst_id, "msg::A").unwrap();
4799        graph
4800            .get_node_mut(src_id)
4801            .expect("missing source node")
4802            .add_nc_output("msg::B", usize::MAX);
4803
4804        let runtime = compute_runtime_plan(graph).unwrap();
4805        let src_step = runtime
4806            .steps
4807            .iter()
4808            .find_map(|step| match step {
4809                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4810                _ => None,
4811            })
4812            .unwrap();
4813        let dst_step = runtime
4814            .steps
4815            .iter()
4816            .find_map(|step| match step {
4817                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
4818                _ => None,
4819            })
4820            .unwrap();
4821
4822        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4823        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
4824        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 0);
4825    }
4826
4827    #[test]
4828    fn test_runtime_plan_infers_regular_task_when_outputs_are_nc_only() {
4829        let txt = r#"(
4830            tasks: [
4831                (id: "src", type: "a"),
4832                (id: "regular", type: "b"),
4833            ],
4834            cnx: [
4835                (src: "src", dst: "regular", msg: "msg::A"),
4836                (src: "regular", dst: "__nc__", msg: "msg::B"),
4837            ]
4838        )"#;
4839        let config = CuConfig::deserialize_ron(txt).unwrap();
4840        let graph = config.get_graph(None).unwrap();
4841        let regular_id = graph.get_node_id_by_name("regular").unwrap();
4842
4843        let runtime = compute_runtime_plan(graph).unwrap();
4844        let regular_step = runtime
4845            .steps
4846            .iter()
4847            .find_map(|step| match step {
4848                CuExecutionUnit::Step(step) if step.node_id == regular_id => Some(step),
4849                _ => None,
4850            })
4851            .unwrap();
4852
4853        assert_eq!(regular_step.task_type, CuTaskType::Regular);
4854        assert_eq!(
4855            regular_step.output_msg_pack.as_ref().unwrap().msg_types,
4856            vec!["msg::B"]
4857        );
4858    }
4859
4860    #[test]
4861    fn test_runtime_output_ports_respect_connection_order_with_nc() {
4862        let txt = r#"(
4863            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4864            cnx: [
4865                (src: "src", dst: "__nc__", msg: "msg::A"),
4866                (src: "src", dst: "sink", msg: "msg::B"),
4867            ]
4868        )"#;
4869        let config = CuConfig::deserialize_ron(txt).unwrap();
4870        let graph = config.get_graph(None).unwrap();
4871        let src_id = graph.get_node_id_by_name("src").unwrap();
4872        let dst_id = graph.get_node_id_by_name("sink").unwrap();
4873
4874        let runtime = compute_runtime_plan(graph).unwrap();
4875        let src_step = runtime
4876            .steps
4877            .iter()
4878            .find_map(|step| match step {
4879                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4880                _ => None,
4881            })
4882            .unwrap();
4883        let dst_step = runtime
4884            .steps
4885            .iter()
4886            .find_map(|step| match step {
4887                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
4888                _ => None,
4889            })
4890            .unwrap();
4891
4892        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4893        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
4894        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
4895    }
4896
4897    #[cfg(feature = "std")]
4898    #[test]
4899    fn test_runtime_output_ports_respect_connection_order_with_nc_from_file() {
4900        let txt = r#"(
4901            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4902            cnx: [
4903                (src: "src", dst: "__nc__", msg: "msg::A"),
4904                (src: "src", dst: "sink", msg: "msg::B"),
4905            ]
4906        )"#;
4907        let tmp = tempfile::NamedTempFile::new().unwrap();
4908        std::fs::write(tmp.path(), txt).unwrap();
4909        let config = crate::config::read_configuration(tmp.path().to_str().unwrap()).unwrap();
4910        let graph = config.get_graph(None).unwrap();
4911        let src_id = graph.get_node_id_by_name("src").unwrap();
4912        let dst_id = graph.get_node_id_by_name("sink").unwrap();
4913
4914        let runtime = compute_runtime_plan(graph).unwrap();
4915        let src_step = runtime
4916            .steps
4917            .iter()
4918            .find_map(|step| match step {
4919                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4920                _ => None,
4921            })
4922            .unwrap();
4923        let dst_step = runtime
4924            .steps
4925            .iter()
4926            .find_map(|step| match step {
4927                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
4928                _ => None,
4929            })
4930            .unwrap();
4931
4932        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4933        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
4934        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
4935    }
4936
4937    #[test]
4938    fn test_runtime_output_ports_respect_connection_order_with_nc_primitives() {
4939        let txt = r#"(
4940            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
4941            cnx: [
4942                (src: "src", dst: "__nc__", msg: "i32"),
4943                (src: "src", dst: "sink", msg: "bool"),
4944            ]
4945        )"#;
4946        let config = CuConfig::deserialize_ron(txt).unwrap();
4947        let graph = config.get_graph(None).unwrap();
4948        let src_id = graph.get_node_id_by_name("src").unwrap();
4949        let dst_id = graph.get_node_id_by_name("sink").unwrap();
4950
4951        let runtime = compute_runtime_plan(graph).unwrap();
4952        let src_step = runtime
4953            .steps
4954            .iter()
4955            .find_map(|step| match step {
4956                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
4957                _ => None,
4958            })
4959            .unwrap();
4960        let dst_step = runtime
4961            .steps
4962            .iter()
4963            .find_map(|step| match step {
4964                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
4965                _ => None,
4966            })
4967            .unwrap();
4968
4969        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
4970        assert_eq!(output_pack.msg_types, vec!["i32", "bool"]);
4971        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
4972    }
4973
4974    #[test]
4975    fn test_runtime_plan_diamond_case1() {
4976        // more complex topology that tripped the scheduler
4977        let mut config = CuConfig::default();
4978        let graph = config.get_graph_mut(None).unwrap();
4979        let cam0_id = graph
4980            .add_node(Node::new("cam0", "tasks::IntegerSrcTask"))
4981            .unwrap();
4982        let inf0_id = graph
4983            .add_node(Node::new("inf0", "tasks::Integer2FloatTask"))
4984            .unwrap();
4985        let broadcast_id = graph
4986            .add_node(Node::new("broadcast", "tasks::MergingSinkTask"))
4987            .unwrap();
4988
4989        // case 1 order
4990        graph.connect(cam0_id, broadcast_id, "i32").unwrap();
4991        graph.connect(cam0_id, inf0_id, "i32").unwrap();
4992        graph.connect(inf0_id, broadcast_id, "f32").unwrap();
4993
4994        let edge_cam0_to_broadcast = *graph.get_src_edges(cam0_id).unwrap().first().unwrap();
4995        let edge_cam0_to_inf0 = graph.get_src_edges(cam0_id).unwrap()[1];
4996
4997        assert_eq!(edge_cam0_to_inf0, 0);
4998        assert_eq!(edge_cam0_to_broadcast, 1);
4999
5000        let runtime = compute_runtime_plan(graph).unwrap();
5001        let broadcast_step = runtime
5002            .steps
5003            .iter()
5004            .find_map(|step| match step {
5005                CuExecutionUnit::Step(step) if step.node_id == broadcast_id => Some(step),
5006                _ => None,
5007            })
5008            .unwrap();
5009
5010        assert_eq!(broadcast_step.input_msg_indices_types[0].msg_type, "i32");
5011        assert_eq!(broadcast_step.input_msg_indices_types[1].msg_type, "f32");
5012    }
5013
5014    #[test]
5015    fn test_runtime_plan_diamond_case2() {
5016        // more complex topology that tripped the scheduler variation 2
5017        let mut config = CuConfig::default();
5018        let graph = config.get_graph_mut(None).unwrap();
5019        let cam0_id = graph
5020            .add_node(Node::new("cam0", "tasks::IntegerSrcTask"))
5021            .unwrap();
5022        let inf0_id = graph
5023            .add_node(Node::new("inf0", "tasks::Integer2FloatTask"))
5024            .unwrap();
5025        let broadcast_id = graph
5026            .add_node(Node::new("broadcast", "tasks::MergingSinkTask"))
5027            .unwrap();
5028
5029        // case 2 order
5030        graph.connect(cam0_id, inf0_id, "i32").unwrap();
5031        graph.connect(cam0_id, broadcast_id, "i32").unwrap();
5032        graph.connect(inf0_id, broadcast_id, "f32").unwrap();
5033
5034        let edge_cam0_to_inf0 = *graph.get_src_edges(cam0_id).unwrap().first().unwrap();
5035        let edge_cam0_to_broadcast = graph.get_src_edges(cam0_id).unwrap()[1];
5036
5037        assert_eq!(edge_cam0_to_broadcast, 0);
5038        assert_eq!(edge_cam0_to_inf0, 1);
5039
5040        let runtime = compute_runtime_plan(graph).unwrap();
5041        let broadcast_step = runtime
5042            .steps
5043            .iter()
5044            .find_map(|step| match step {
5045                CuExecutionUnit::Step(step) if step.node_id == broadcast_id => Some(step),
5046                _ => None,
5047            })
5048            .unwrap();
5049
5050        assert_eq!(broadcast_step.input_msg_indices_types[0].msg_type, "i32");
5051        assert_eq!(broadcast_step.input_msg_indices_types[1].msg_type, "f32");
5052    }
5053
5054    // --- anytime plan expansion ---
5055
5056    use crate::config::AnytimeConfig;
5057
5058    fn anytime_node(id: &str, max_refines: Option<u32>) -> Node {
5059        let mut node = Node::new(id, "tasks::AnytimeTask");
5060        node.set_anytime(Some(AnytimeConfig {
5061            max_refines,
5062            ..Default::default()
5063        }));
5064        node
5065    }
5066
5067    /// Renders the plan as `(node_id, phase)` pairs for compact assertions.
5068    fn plan_shape(plan: &CuExecutionLoop) -> Vec<(NodeId, CuStepPhase)> {
5069        plan.steps
5070            .iter()
5071            .map(|unit| match unit {
5072                CuExecutionUnit::Step(step) => (step.node_id, step.phase),
5073                CuExecutionUnit::Loop(_) => panic!("no loops expected"),
5074            })
5075            .collect()
5076    }
5077
5078    /// A manual step, bypassing the planner heuristic so gap placement is
5079    /// deterministic: `inputs`/`output` are copperlist indices.
5080    fn manual_step(node: Node, node_id: NodeId, inputs: &[u32], output: u32) -> CuExecutionUnit {
5081        CuExecutionUnit::Step(Box::new(CuExecutionStep {
5082            node_id,
5083            node,
5084            task_type: CuTaskType::Regular,
5085            phase: CuStepPhase::default(),
5086            input_msg_indices_types: inputs
5087                .iter()
5088                .map(|&culist_index| CuInputMsg {
5089                    culist_index,
5090                    msg_type: "msg::A".to_string(),
5091                    src_port: 0,
5092                    edge_id: 0,
5093                    connection_order: 0,
5094                })
5095                .collect(),
5096            output_msg_pack: Some(CuOutputPack {
5097                culist_index: output,
5098                msg_types: vec!["msg::A".to_string()],
5099                src_channels: vec![None],
5100            }),
5101        }))
5102    }
5103
5104    #[test]
5105    fn test_anytime_expansion_contiguous_without_gap() {
5106        // src -> any -> sink through the real planner: no independent steps
5107        // between the node and its consumer, so every refine sits before it.
5108        let mut config = CuConfig::default();
5109        let graph = config.get_graph_mut(None).unwrap();
5110        let src_id = graph.add_node(Node::new("src", "tasks::Src")).unwrap();
5111        let any_id = graph.add_node(anytime_node("any", Some(3))).unwrap();
5112        let sink_id = graph.add_node(Node::new("sink", "tasks::Sink")).unwrap();
5113        graph.connect(src_id, any_id, "msg::A").unwrap();
5114        graph.connect(any_id, sink_id, "msg::B").unwrap();
5115
5116        let mut plan = compute_runtime_plan(graph).unwrap();
5117        expand_anytime_steps(&mut plan).unwrap();
5118
5119        assert_eq!(
5120            plan_shape(&plan),
5121            vec![
5122                (src_id, CuStepPhase::Whole),
5123                (any_id, CuStepPhase::AnytimeBase),
5124                (any_id, CuStepPhase::AnytimeRefine),
5125                (any_id, CuStepPhase::AnytimeRefine),
5126                (any_id, CuStepPhase::AnytimeRefine),
5127                (sink_id, CuStepPhase::Whole),
5128            ]
5129        );
5130
5131        // Refine steps read no input and write the base step's output slot.
5132        let (base_pack, refine_steps): (Option<CuOutputPack>, Vec<&CuExecutionStep>) = {
5133            let mut base_pack = None;
5134            let mut refines = Vec::new();
5135            for unit in &plan.steps {
5136                if let CuExecutionUnit::Step(step) = unit {
5137                    match step.phase {
5138                        CuStepPhase::AnytimeBase => base_pack = step.output_msg_pack.clone(),
5139                        CuStepPhase::AnytimeRefine => refines.push(step.as_ref()),
5140                        CuStepPhase::Whole => {}
5141                    }
5142                }
5143            }
5144            (base_pack, refines)
5145        };
5146        let base_pack = base_pack.unwrap();
5147        for refine in refine_steps {
5148            assert!(refine.input_msg_indices_types.is_empty());
5149            let pack = refine.output_msg_pack.as_ref().unwrap();
5150            assert_eq!(pack.culist_index, base_pack.culist_index);
5151        }
5152    }
5153
5154    #[test]
5155    fn test_anytime_expansion_interleaves_with_gap_steps() {
5156        // Manual plan: [any(base at 1), gapA, gapB, consumer], R = 4.
5157        // Expected: base, r, gapA, r, gapB, r, r, consumer.
5158        let mut plan = CuExecutionLoop {
5159            steps: vec![
5160                manual_step(anytime_node("any", Some(4)), 0, &[], 0),
5161                manual_step(Node::new("gap_a", "t"), 1, &[], 1),
5162                manual_step(Node::new("gap_b", "t"), 2, &[], 2),
5163                manual_step(Node::new("consumer", "t"), 3, &[0], 3),
5164            ],
5165            loop_count: None,
5166        };
5167        expand_anytime_steps(&mut plan).unwrap();
5168        assert_eq!(
5169            plan_shape(&plan),
5170            vec![
5171                (0, CuStepPhase::AnytimeBase),
5172                (0, CuStepPhase::AnytimeRefine),
5173                (1, CuStepPhase::Whole),
5174                (0, CuStepPhase::AnytimeRefine),
5175                (2, CuStepPhase::Whole),
5176                (0, CuStepPhase::AnytimeRefine),
5177                (0, CuStepPhase::AnytimeRefine),
5178                (3, CuStepPhase::Whole),
5179            ]
5180        );
5181    }
5182
5183    #[test]
5184    fn test_anytime_expansion_fewer_refines_than_gaps() {
5185        // R = 2 with two gap steps: later gap steps get no quantum after them.
5186        let mut plan = CuExecutionLoop {
5187            steps: vec![
5188                manual_step(anytime_node("any", Some(2)), 0, &[], 0),
5189                manual_step(Node::new("gap_a", "t"), 1, &[], 1),
5190                manual_step(Node::new("gap_b", "t"), 2, &[], 2),
5191                manual_step(Node::new("consumer", "t"), 3, &[0], 3),
5192            ],
5193            loop_count: None,
5194        };
5195        expand_anytime_steps(&mut plan).unwrap();
5196        assert_eq!(
5197            plan_shape(&plan),
5198            vec![
5199                (0, CuStepPhase::AnytimeBase),
5200                (0, CuStepPhase::AnytimeRefine),
5201                (1, CuStepPhase::Whole),
5202                (0, CuStepPhase::AnytimeRefine),
5203                (2, CuStepPhase::Whole),
5204                (3, CuStepPhase::Whole),
5205            ]
5206        );
5207    }
5208
5209    #[test]
5210    fn test_anytime_expansion_without_consumer() {
5211        // No step consumes the node's output: refines sit right after the base.
5212        let mut plan = CuExecutionLoop {
5213            steps: vec![
5214                manual_step(anytime_node("any", Some(2)), 0, &[], 0),
5215                manual_step(Node::new("other", "t"), 1, &[], 1),
5216            ],
5217            loop_count: None,
5218        };
5219        expand_anytime_steps(&mut plan).unwrap();
5220        assert_eq!(
5221            plan_shape(&plan),
5222            vec![
5223                (0, CuStepPhase::AnytimeBase),
5224                (0, CuStepPhase::AnytimeRefine),
5225                (0, CuStepPhase::AnytimeRefine),
5226                (1, CuStepPhase::Whole),
5227            ]
5228        );
5229    }
5230
5231    #[test]
5232    fn test_anytime_expansion_two_nodes_interleave() {
5233        // Two anytime nodes: each expands independently; the second node's base
5234        // and quanta land in the first node's gap and vice versa.
5235        let mut plan = CuExecutionLoop {
5236            steps: vec![
5237                manual_step(anytime_node("any_a", Some(2)), 0, &[], 0),
5238                manual_step(anytime_node("any_b", Some(2)), 1, &[], 1),
5239                manual_step(Node::new("consumer", "t"), 2, &[0, 1], 2),
5240            ],
5241            loop_count: None,
5242        };
5243        expand_anytime_steps(&mut plan).unwrap();
5244        // A expands first: base_a, r_a, [b], r_a, consumer. B then expands in
5245        // place, treating A's second quantum as its gap step.
5246        assert_eq!(
5247            plan_shape(&plan),
5248            vec![
5249                (0, CuStepPhase::AnytimeBase),
5250                (0, CuStepPhase::AnytimeRefine),
5251                (1, CuStepPhase::AnytimeBase),
5252                (1, CuStepPhase::AnytimeRefine),
5253                (0, CuStepPhase::AnytimeRefine),
5254                (1, CuStepPhase::AnytimeRefine),
5255                (2, CuStepPhase::Whole),
5256            ]
5257        );
5258    }
5259
5260    #[test]
5261    fn test_anytime_expansion_requires_max_refines() {
5262        let mut plan = CuExecutionLoop {
5263            steps: vec![
5264                manual_step(anytime_node("any", None), 0, &[], 0),
5265                manual_step(Node::new("consumer", "t"), 1, &[0], 1),
5266            ],
5267            loop_count: None,
5268        };
5269        let err = expand_anytime_steps(&mut plan).unwrap_err();
5270        assert!(err.to_string().contains("needs anytime.max_refines"));
5271    }
5272}