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;
6use crate::config::{ComponentConfig, DEFAULT_KEYFRAME_INTERVAL, Node, TaskKind};
7use crate::config::{
8    CuConfig, CuGraph, MAX_RATE_TARGET_HZ, NodeId, RuntimeConfig, resolve_task_kind_for_id,
9};
10use crate::copperlist::{CopperList, CopperListState, CuListZeroedInit, CuListsManager};
11use crate::cutask::{BincodeAdapter, Freezable};
12#[cfg(feature = "std")]
13use crate::monitoring::ExecutionProbeHandle;
14#[cfg(feature = "std")]
15use crate::monitoring::MonitorExecutionProbe;
16use crate::monitoring::{
17    ComponentId, CopperListInfo, CuMonitor, CuMonitoringMetadata, CuMonitoringRuntime,
18    ExecutionMarker, MonitorComponentMetadata, RuntimeExecutionProbe, build_monitor_topology,
19    take_last_completed_handle_bytes,
20};
21#[cfg(all(feature = "std", feature = "parallel-rt"))]
22use crate::parallel_rt::{ParallelRt, ParallelRtMetadata};
23use crate::planner::{CuPlanner, Linearity, check_order, plan_from_order};
24use crate::resource::ResourceManager;
25#[cfg(feature = "std")]
26use alloc::sync::Arc;
27use compact_str::CompactString;
28use cu29_clock::{ClockProvider, CuDuration, CuTime, RobotClock};
29use cu29_traits::CuResult;
30use cu29_traits::WriteStream;
31use cu29_traits::{CopperListTuple, CuError};
32#[cfg(feature = "std")]
33use rayon::ThreadPool;
34
35#[cfg(target_os = "none")]
36#[allow(unused_imports)]
37use cu29_log::{ANONYMOUS, CuLogEntry, CuLogLevel};
38#[cfg(target_os = "none")]
39#[allow(unused_imports)]
40use cu29_log_derive::info;
41#[cfg(target_os = "none")]
42#[allow(unused_imports)]
43use cu29_log_runtime::log;
44#[cfg(all(target_os = "none", debug_assertions))]
45#[allow(unused_imports)]
46use cu29_log_runtime::log_debug_mode;
47#[cfg(target_os = "none")]
48#[allow(unused_imports)]
49use cu29_value::to_value;
50
51#[cfg(all(feature = "std", any(feature = "async-cl-io", feature = "parallel-rt")))]
52use alloc::alloc::{alloc_zeroed, handle_alloc_error};
53use alloc::boxed::Box;
54use alloc::format;
55use alloc::string::{String, ToString};
56use alloc::vec::Vec;
57use bincode::de::read::Reader;
58use bincode::de::{Decoder, DecoderImpl};
59use bincode::enc::EncoderImpl;
60use bincode::enc::write::{SizeWriter, Writer};
61use bincode::error::{DecodeError, EncodeError};
62use bincode::{Decode, Encode};
63#[cfg(all(feature = "std", any(feature = "async-cl-io", feature = "parallel-rt")))]
64use core::alloc::Layout;
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 std::sync::mpsc::{Receiver, SyncSender, TryRecvError, sync_channel};
71#[cfg(all(feature = "std", feature = "async-cl-io"))]
72use std::thread::JoinHandle;
73
74#[cfg(feature = "std")]
75#[doc(hidden)]
76pub type TasksInstantiator<CT> = for<'c> fn(
77    Vec<Option<&'c ComponentConfig>>,
78    &mut ResourceManager,
79    &[Option<Arc<ThreadPool>>],
80) -> CuResult<CT>;
81#[cfg(not(feature = "std"))]
82#[doc(hidden)]
83pub type TasksInstantiator<CT> =
84    for<'c> fn(Vec<Option<&'c ComponentConfig>>, &mut ResourceManager) -> CuResult<CT>;
85#[doc(hidden)]
86pub type BridgesInstantiator<CB> = fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>;
87/// Instantiates the rayon thread pools described by `runtime.thread_pools`.
88///
89/// Returned vector is indexed positionally to `runtime.thread_pools`; reserved
90/// pool ids (such as [`crate::config::RT_POOL`]) leave a `None` slot since they
91/// are applied directly to runtime-owned worker threads rather than borrowed as
92/// a rayon pool.
93#[cfg(feature = "std")]
94#[doc(hidden)]
95pub type ThreadPoolsInstantiator = fn(&CuConfig) -> CuResult<Vec<Option<Arc<ThreadPool>>>>;
96#[doc(hidden)]
97pub type MonitorInstantiator<M> = fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M;
98
99#[doc(hidden)]
100pub struct CuRuntimeParts<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize, TI, BI, MI> {
101    pub tasks_instanciator: TI,
102    pub monitored_components: &'static [MonitorComponentMetadata],
103    pub culist_component_mapping: &'static [ComponentId],
104    #[cfg(all(feature = "std", feature = "parallel-rt"))]
105    pub parallel_rt_metadata: &'static ParallelRtMetadata,
106    pub monitor_instanciator: MI,
107    pub bridges_instanciator: BI,
108    _payload: PhantomData<(CT, CB, P, M, [(); NBCL])>,
109}
110
111impl<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize, TI, BI, MI>
112    CuRuntimeParts<CT, CB, P, M, NBCL, TI, BI, MI>
113{
114    pub const fn new(
115        tasks_instanciator: TI,
116        monitored_components: &'static [MonitorComponentMetadata],
117        culist_component_mapping: &'static [ComponentId],
118        #[cfg(all(feature = "std", feature = "parallel-rt"))]
119        parallel_rt_metadata: &'static ParallelRtMetadata,
120        monitor_instanciator: MI,
121        bridges_instanciator: BI,
122    ) -> Self {
123        Self {
124            tasks_instanciator,
125            monitored_components,
126            culist_component_mapping,
127            #[cfg(all(feature = "std", feature = "parallel-rt"))]
128            parallel_rt_metadata,
129            monitor_instanciator,
130            bridges_instanciator,
131            _payload: PhantomData,
132        }
133    }
134}
135
136#[doc(hidden)]
137pub struct CuRuntimeBuilder<
138    'cfg,
139    CT,
140    CB,
141    P: CopperListTuple,
142    M: CuMonitor,
143    const NBCL: usize,
144    TI,
145    BI,
146    MI,
147    CLW,
148    KFW,
149> {
150    clock: RobotClock,
151    config: &'cfg CuConfig,
152    mission: &'cfg str,
153    subsystem: Subsystem,
154    instance_id: u32,
155    resources: Option<ResourceManager>,
156    #[cfg(feature = "std")]
157    thread_pools: Option<Vec<Option<Arc<ThreadPool>>>>,
158    parts: CuRuntimeParts<CT, CB, P, M, NBCL, TI, BI, MI>,
159    copperlists_logger: CLW,
160    keyframes_logger: KFW,
161}
162
163impl<'cfg, CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize, TI, BI, MI, CLW, KFW>
164    CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLW, KFW>
165{
166    pub fn new(
167        clock: RobotClock,
168        config: &'cfg CuConfig,
169        mission: &'cfg str,
170        parts: CuRuntimeParts<CT, CB, P, M, NBCL, TI, BI, MI>,
171        copperlists_logger: CLW,
172        keyframes_logger: KFW,
173    ) -> Self {
174        Self {
175            clock,
176            config,
177            mission,
178            subsystem: Subsystem::new(None, 0),
179            instance_id: 0,
180            resources: None,
181            #[cfg(feature = "std")]
182            thread_pools: None,
183            parts,
184            copperlists_logger,
185            keyframes_logger,
186        }
187    }
188
189    pub fn with_subsystem(mut self, subsystem: Subsystem) -> Self {
190        self.subsystem = subsystem;
191        self
192    }
193
194    pub fn with_instance_id(mut self, instance_id: u32) -> Self {
195        self.instance_id = instance_id;
196        self
197    }
198
199    pub fn with_resources(mut self, resources: ResourceManager) -> Self {
200        self.resources = Some(resources);
201        self
202    }
203
204    pub fn try_with_resources_instantiator(
205        mut self,
206        resources_instantiator: impl FnOnce(&CuConfig) -> CuResult<ResourceManager>,
207    ) -> CuResult<Self> {
208        self.resources = Some(resources_instantiator(self.config)?);
209        Ok(self)
210    }
211
212    /// Provides pre-built thread pools; positions in the slice must match
213    /// `runtime.thread_pools` indices. Reserved pool ids (e.g. `"rt"`) belong
214    /// to runtime-owned worker threads and stay as `None` slots.
215    #[cfg(feature = "std")]
216    pub fn with_thread_pools(mut self, pools: Vec<Option<Arc<ThreadPool>>>) -> Self {
217        self.thread_pools = Some(pools);
218        self
219    }
220
221    #[cfg(feature = "std")]
222    pub fn try_with_thread_pools_instantiator(
223        mut self,
224        thread_pools_instantiator: impl FnOnce(&CuConfig) -> CuResult<Vec<Option<Arc<ThreadPool>>>>,
225    ) -> CuResult<Self> {
226        self.thread_pools = Some(thread_pools_instantiator(self.config)?);
227        Ok(self)
228    }
229}
230
231/// Returns a monotonic instant used for local runtime performance timing.
232///
233/// When `sysclock-perf` (and `std`) are enabled this uses a process-local
234/// `RobotClock::new()` instance for timing. The returned value is a
235/// monotonically increasing duration since an unspecified origin (typically
236/// process or runtime initialization), not a wall-clock time-of-day. When
237/// `sysclock-perf` is disabled it delegates to the provided `RobotClock`.
238///
239/// This is intentionally separate from `LoopRateLimiter`, which always uses the
240/// provided `RobotClock` so `runtime.rate_target_hz` stays tied to robot time.
241#[inline]
242pub fn perf_now(_clock: &RobotClock) -> CuTime {
243    #[cfg(all(feature = "std", feature = "sysclock-perf"))]
244    {
245        static PERF_CLOCK: std::sync::OnceLock<RobotClock> = std::sync::OnceLock::new();
246        return PERF_CLOCK.get_or_init(RobotClock::new).now();
247    }
248
249    #[allow(unreachable_code)]
250    _clock.now()
251}
252
253#[cfg(all(feature = "std", feature = "high-precision-limiter"))]
254const HIGH_PRECISION_LIMITER_SPIN_WINDOW_NS: u64 = 200_000;
255
256/// Convert a configured runtime rate target to an integer-nanosecond period.
257#[inline]
258pub fn rate_target_period(rate_target_hz: u64) -> CuResult<CuDuration> {
259    if rate_target_hz == 0 {
260        return Err(CuError::from(
261            "Runtime rate target cannot be zero. Set runtime.rate_target_hz to at least 1.",
262        ));
263    }
264
265    if rate_target_hz > MAX_RATE_TARGET_HZ {
266        return Err(CuError::from(format!(
267            "Runtime rate target ({rate_target_hz} Hz) exceeds the supported maximum of {MAX_RATE_TARGET_HZ} Hz."
268        )));
269    }
270
271    Ok(CuDuration::from(MAX_RATE_TARGET_HZ / rate_target_hz))
272}
273
274/// Runtime loop limiter that preserves phase with absolute deadlines.
275///
276/// This is intentionally a small runtime helper so generated applications do
277/// not have to open-code loop scheduling policy. Deadlines are tracked against
278/// the provided `RobotClock`, even when `sysclock-perf` is enabled for
279/// process-time measurements.
280#[derive(Clone, Copy, Debug, PartialEq, Eq)]
281pub struct LoopRateLimiter {
282    period: CuDuration,
283    next_deadline: CuTime,
284}
285
286impl LoopRateLimiter {
287    #[inline]
288    pub fn from_rate_target_hz(rate_target_hz: u64, clock: &RobotClock) -> CuResult<Self> {
289        let period = rate_target_period(rate_target_hz)?;
290        Ok(Self {
291            period,
292            next_deadline: clock.now() + period,
293        })
294    }
295
296    #[inline]
297    pub fn is_ready(&self, clock: &RobotClock) -> bool {
298        self.remaining(clock).is_none()
299    }
300
301    #[inline]
302    pub fn remaining(&self, clock: &RobotClock) -> Option<CuDuration> {
303        let now = clock.now();
304        if now < self.next_deadline {
305            Some(self.next_deadline - now)
306        } else {
307            None
308        }
309    }
310
311    #[inline]
312    pub fn wait_until_ready(&self, clock: &RobotClock) {
313        let deadline = self.next_deadline;
314        let Some(remaining) = self.remaining(clock) else {
315            return;
316        };
317
318        #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
319        {
320            let spin_window = self.spin_window();
321            if remaining > spin_window {
322                std::thread::sleep(std::time::Duration::from(remaining - spin_window));
323            }
324            while clock.now() < deadline {
325                core::hint::spin_loop();
326            }
327        }
328
329        #[cfg(all(feature = "std", not(feature = "high-precision-limiter")))]
330        {
331            let _ = deadline;
332            std::thread::sleep(std::time::Duration::from(remaining));
333        }
334
335        #[cfg(not(feature = "std"))]
336        {
337            let _ = remaining;
338            while clock.now() < deadline {
339                core::hint::spin_loop();
340            }
341        }
342    }
343
344    #[inline]
345    pub fn mark_tick(&mut self, clock: &RobotClock) {
346        self.advance_from(clock.now());
347    }
348
349    #[inline]
350    pub fn limit(&mut self, clock: &RobotClock) {
351        self.wait_until_ready(clock);
352        self.mark_tick(clock);
353    }
354
355    #[inline]
356    fn advance_from(&mut self, now: CuTime) {
357        let steps = if now < self.next_deadline {
358            1
359        } else {
360            (now - self.next_deadline).as_nanos() / self.period.as_nanos() + 1
361        };
362        self.next_deadline += steps * self.period;
363    }
364
365    #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
366    #[inline]
367    fn spin_window(&self) -> CuDuration {
368        let _ = self.period;
369        CuDuration::from(HIGH_PRECISION_LIMITER_SPIN_WINDOW_NS)
370    }
371
372    #[cfg(test)]
373    #[inline]
374    fn next_deadline(&self) -> CuTime {
375        self.next_deadline
376    }
377}
378
379#[cfg(all(feature = "std", feature = "async-cl-io"))]
380#[doc(hidden)]
381pub trait AsyncCopperListPayload: Send {}
382
383#[cfg(all(feature = "std", feature = "async-cl-io"))]
384impl<T: Send> AsyncCopperListPayload for T {}
385
386#[cfg(not(all(feature = "std", feature = "async-cl-io")))]
387#[doc(hidden)]
388pub trait AsyncCopperListPayload {}
389
390#[cfg(not(all(feature = "std", feature = "async-cl-io")))]
391impl<T> AsyncCopperListPayload for T {}
392
393/// Control-flow result returned by one generated process stage.
394///
395/// `AbortCopperList` preserves the current runtime semantics for monitor
396/// decisions that abort the current CopperList without shutting the runtime
397/// down. The outer driver remains responsible for ordered cleanup and log
398/// handoff.
399#[derive(Clone, Copy, Debug, PartialEq, Eq)]
400#[doc(hidden)]
401pub enum ProcessStepOutcome {
402    Continue,
403    AbortCopperList,
404}
405
406/// Result type used by generated process-step functions.
407#[doc(hidden)]
408pub type ProcessStepResult = CuResult<ProcessStepOutcome>;
409
410#[cfg(feature = "remote-debug")]
411fn encode_completed_copperlist_snapshot<P: CopperListTuple>(
412    cl: &CopperList<P>,
413) -> CuResult<Vec<u8>> {
414    bincode::encode_to_vec(cl, bincode::config::standard())
415        .map_err(|e| CuError::new_with_cause("Failed to encode completed CopperList snapshot", e))
416}
417
418/// Manages the lifecycle of the copper lists and logging on the synchronous path.
419#[doc(hidden)]
420pub struct SyncCopperListsManager<P: CopperListTuple + Default, const NBCL: usize> {
421    inner: CuListsManager<P, NBCL>,
422    /// Logger for the copper lists (messages between tasks)
423    logger: Option<Box<dyn WriteStream<CopperList<P>>>>,
424    /// Remote-debug snapshot of the most recently completed CopperList.
425    #[cfg(feature = "remote-debug")]
426    last_completed_encoded: Option<Vec<u8>>,
427    /// Last encoded size returned by logger.log
428    pub last_encoded_bytes: u64,
429    /// Last handle-backed payload bytes observed during logger.log
430    pub last_handle_bytes: u64,
431}
432
433impl<P: CopperListTuple + Default, const NBCL: usize> SyncCopperListsManager<P, NBCL> {
434    pub fn new(logger: Option<Box<dyn WriteStream<CopperList<P>>>>) -> CuResult<Self>
435    where
436        P: CuListZeroedInit,
437    {
438        Ok(Self {
439            inner: CuListsManager::new(),
440            logger,
441            #[cfg(feature = "remote-debug")]
442            last_completed_encoded: None,
443            last_encoded_bytes: 0,
444            last_handle_bytes: 0,
445        })
446    }
447
448    pub fn next_cl_id(&self) -> u64 {
449        self.inner.next_cl_id()
450    }
451
452    pub fn last_cl_id(&self) -> u64 {
453        self.inner.last_cl_id()
454    }
455
456    pub fn peek(&self) -> Option<&CopperList<P>> {
457        self.inner.peek()
458    }
459
460    #[cfg(feature = "remote-debug")]
461    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
462        self.last_completed_encoded.as_deref()
463    }
464
465    #[cfg(not(feature = "remote-debug"))]
466    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
467        None
468    }
469
470    #[cfg(feature = "remote-debug")]
471    pub fn set_last_completed_encoded(&mut self, snapshot: Option<Vec<u8>>) {
472        self.last_completed_encoded = snapshot;
473    }
474
475    #[cfg(not(feature = "remote-debug"))]
476    pub fn set_last_completed_encoded(&mut self, _snapshot: Option<Vec<u8>>) {}
477
478    pub fn create(&mut self) -> CuResult<&mut CopperList<P>>
479    where
480        P: CuListZeroedInit,
481    {
482        self.inner
483            .create()
484            .ok_or_else(|| CuError::from("Ran out of space for copper lists"))
485    }
486
487    pub fn end_of_processing(&mut self, culistid: u64) -> CuResult<()> {
488        #[cfg(debug_assertions)]
489        self.debug_assert_end_of_processing_target(culistid);
490
491        let mut is_top = true;
492        let mut nb_done = 0;
493        self.last_encoded_bytes = 0;
494        self.last_handle_bytes = 0;
495        #[cfg(feature = "remote-debug")]
496        let last_completed_encoded = &mut self.last_completed_encoded;
497        for cl in self.inner.iter_mut() {
498            if cl.id == culistid && cl.get_state() == CopperListState::Processing {
499                cl.change_state(CopperListState::DoneProcessing);
500                #[cfg(feature = "remote-debug")]
501                {
502                    *last_completed_encoded = Some(encode_completed_copperlist_snapshot(cl)?);
503                }
504            }
505            if is_top && cl.get_state() == CopperListState::DoneProcessing {
506                if let Some(logger) = &mut self.logger {
507                    cl.change_state(CopperListState::BeingSerialized);
508                    logger.log(cl)?;
509                    self.last_encoded_bytes = logger.last_log_bytes().unwrap_or(0) as u64;
510                    self.last_handle_bytes = take_last_completed_handle_bytes();
511                }
512                cl.change_state(CopperListState::Free);
513                nb_done += 1;
514            } else {
515                is_top = false;
516            }
517        }
518        for _ in 0..nb_done {
519            let _ = self.inner.pop();
520        }
521        Ok(())
522    }
523
524    pub fn finish_pending(&mut self) -> CuResult<()> {
525        Ok(())
526    }
527
528    pub fn available_copper_lists(&mut self) -> CuResult<usize> {
529        Ok(NBCL - self.inner.len())
530    }
531
532    #[cfg(feature = "std")]
533    pub fn end_of_processing_boxed(
534        &mut self,
535        mut culist: Box<CopperList<P>>,
536    ) -> CuResult<OwnedCopperListSubmission<P>> {
537        #[cfg(debug_assertions)]
538        debug_assert_processing_completion_state(culist.as_ref(), "sync boxed end_of_processing");
539
540        culist.change_state(CopperListState::DoneProcessing);
541        self.last_encoded_bytes = 0;
542        self.last_handle_bytes = 0;
543        if let Some(logger) = &mut self.logger {
544            culist.change_state(CopperListState::BeingSerialized);
545            logger.log(&culist)?;
546            self.last_encoded_bytes = logger.last_log_bytes().unwrap_or(0) as u64;
547            self.last_handle_bytes = take_last_completed_handle_bytes();
548        }
549        culist.change_state(CopperListState::Free);
550        Ok(OwnedCopperListSubmission::Recycled(culist))
551    }
552
553    #[cfg(feature = "std")]
554    pub fn try_reclaim_boxed(&mut self) -> CuResult<Option<Box<CopperList<P>>>> {
555        Ok(None)
556    }
557
558    #[cfg(feature = "std")]
559    pub fn wait_reclaim_boxed(&mut self) -> CuResult<Box<CopperList<P>>> {
560        Err(CuError::from(
561            "Synchronous CopperList I/O cannot block waiting for boxed completions",
562        ))
563    }
564
565    #[cfg(feature = "std")]
566    pub fn finish_pending_boxed(&mut self) -> CuResult<Vec<Box<CopperList<P>>>> {
567        Ok(Vec::new())
568    }
569
570    #[cfg(debug_assertions)]
571    fn debug_assert_end_of_processing_target(&self, culistid: u64) {
572        let mut matches = 0usize;
573        let mut state = None;
574        for cl in self.inner.iter() {
575            if cl.id == culistid {
576                matches += 1;
577                state = Some(cl.get_state());
578            }
579        }
580
581        assert_eq!(
582            matches, 1,
583            "sync end_of_processing expected exactly one active CopperList #{culistid}, found {matches}"
584        );
585        assert_eq!(
586            state,
587            Some(CopperListState::Processing),
588            "sync end_of_processing expected CopperList #{culistid} to be Processing, found {:?}",
589            state
590        );
591    }
592}
593
594/// Result of handing an owned boxed CopperList to the runtime-side CL I/O path.
595#[cfg(feature = "std")]
596#[doc(hidden)]
597pub enum OwnedCopperListSubmission<P: CopperListTuple> {
598    /// The CL has been fully handled and can be recycled immediately by the caller.
599    Recycled(Box<CopperList<P>>),
600    /// The CL was queued asynchronously and will be returned by a later reclaim call.
601    Pending,
602}
603
604#[cfg(all(feature = "std", feature = "async-cl-io"))]
605struct AsyncCopperListCompletion<P: CopperListTuple> {
606    culist: Box<CopperList<P>>,
607    log_result: CuResult<(u64, u64)>,
608}
609
610#[cfg(all(feature = "std", any(feature = "async-cl-io", feature = "parallel-rt")))]
611fn allocate_zeroed_copperlist<P>() -> Box<CopperList<P>>
612where
613    P: CopperListTuple + CuListZeroedInit,
614{
615    // SAFETY: We allocate zeroed memory and immediately initialize required fields.
616    let mut culist = unsafe {
617        let layout = Layout::new::<CopperList<P>>();
618        let ptr = alloc_zeroed(layout) as *mut CopperList<P>;
619        if ptr.is_null() {
620            handle_alloc_error(layout);
621        }
622        Box::from_raw(ptr)
623    };
624    culist.msgs.init_zeroed();
625    culist
626}
627
628#[cfg(all(feature = "std", feature = "parallel-rt"))]
629pub fn allocate_boxed_copperlists<P, const NBCL: usize>() -> Vec<Box<CopperList<P>>>
630where
631    P: CopperListTuple + CuListZeroedInit,
632{
633    let mut free_pool = Vec::with_capacity(NBCL);
634    for _ in 0..NBCL {
635        free_pool.push(allocate_zeroed_copperlist::<P>());
636    }
637    free_pool
638}
639
640/// Manages the lifecycle of the copper lists and logging on the asynchronous path.
641#[cfg(all(feature = "std", feature = "async-cl-io"))]
642#[doc(hidden)]
643pub struct AsyncCopperListsManager<P: CopperListTuple + Default, const NBCL: usize> {
644    free_pool: Vec<Box<CopperList<P>>>,
645    current: Option<Box<CopperList<P>>>,
646    #[cfg(feature = "remote-debug")]
647    last_completed_encoded: Option<Vec<u8>>,
648    pending_count: usize,
649    next_cl_id: u64,
650    pending_sender: Option<SyncSender<Box<CopperList<P>>>>,
651    completion_receiver: Option<Receiver<AsyncCopperListCompletion<P>>>,
652    worker_handle: Option<JoinHandle<()>>,
653    /// Last encoded size returned by logger.log
654    pub last_encoded_bytes: u64,
655    /// Last handle-backed payload bytes observed during logger.log
656    pub last_handle_bytes: u64,
657}
658
659#[cfg(all(feature = "std", feature = "async-cl-io"))]
660impl<P: CopperListTuple + Default, const NBCL: usize> AsyncCopperListsManager<P, NBCL> {
661    pub fn new(logger: Option<Box<dyn WriteStream<CopperList<P>>>>) -> CuResult<Self>
662    where
663        P: CuListZeroedInit + AsyncCopperListPayload + 'static,
664    {
665        let mut free_pool = Vec::with_capacity(NBCL);
666        for _ in 0..NBCL {
667            free_pool.push(allocate_zeroed_copperlist::<P>());
668        }
669
670        let (pending_sender, completion_receiver, worker_handle) = if let Some(mut logger) = logger
671        {
672            let (pending_sender, pending_receiver) = sync_channel::<Box<CopperList<P>>>(NBCL);
673            let (completion_sender, completion_receiver) =
674                sync_channel::<AsyncCopperListCompletion<P>>(NBCL);
675            let worker_handle = std::thread::Builder::new()
676                .name("cu-async-cl-io".to_string())
677                .spawn(move || {
678                    while let Ok(mut culist) = pending_receiver.recv() {
679                        culist.change_state(CopperListState::BeingSerialized);
680                        let log_result = logger.log(&culist).map(|_| {
681                            (
682                                logger.last_log_bytes().unwrap_or(0) as u64,
683                                take_last_completed_handle_bytes(),
684                            )
685                        });
686                        let should_stop = log_result.is_err();
687                        if completion_sender
688                            .send(AsyncCopperListCompletion { culist, log_result })
689                            .is_err()
690                        {
691                            break;
692                        }
693                        if should_stop {
694                            break;
695                        }
696                    }
697                })
698                .map_err(|e| {
699                    CuError::from("Failed to spawn async CopperList serializer thread")
700                        .add_cause(e.to_string().as_str())
701                })?;
702            (
703                Some(pending_sender),
704                Some(completion_receiver),
705                Some(worker_handle),
706            )
707        } else {
708            (None, None, None)
709        };
710
711        Ok(Self {
712            free_pool,
713            current: None,
714            #[cfg(feature = "remote-debug")]
715            last_completed_encoded: None,
716            pending_count: 0,
717            next_cl_id: 0,
718            pending_sender,
719            completion_receiver,
720            worker_handle,
721            last_encoded_bytes: 0,
722            last_handle_bytes: 0,
723        })
724    }
725
726    pub fn next_cl_id(&self) -> u64 {
727        self.next_cl_id
728    }
729
730    pub fn last_cl_id(&self) -> u64 {
731        self.next_cl_id.saturating_sub(1)
732    }
733
734    pub fn peek(&self) -> Option<&CopperList<P>> {
735        self.current.as_deref()
736    }
737
738    #[cfg(feature = "remote-debug")]
739    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
740        self.last_completed_encoded.as_deref()
741    }
742
743    #[cfg(not(feature = "remote-debug"))]
744    pub fn last_completed_encoded(&self) -> Option<&[u8]> {
745        None
746    }
747
748    #[cfg(feature = "remote-debug")]
749    pub fn set_last_completed_encoded(&mut self, snapshot: Option<Vec<u8>>) {
750        self.last_completed_encoded = snapshot;
751    }
752
753    #[cfg(not(feature = "remote-debug"))]
754    pub fn set_last_completed_encoded(&mut self, _snapshot: Option<Vec<u8>>) {}
755
756    pub fn create(&mut self) -> CuResult<&mut CopperList<P>>
757    where
758        P: CuListZeroedInit,
759    {
760        if self.current.is_some() {
761            return Err(CuError::from(
762                "Attempted to create a CopperList while another one is still active",
763            ));
764        }
765
766        self.reclaim_completed()?;
767        while self.free_pool.is_empty() {
768            self.wait_for_completion()?;
769        }
770
771        let culist = self
772            .free_pool
773            .pop()
774            .ok_or_else(|| CuError::from("Ran out of space for copper lists"))?;
775        self.current = Some(culist);
776
777        let current = self
778            .current
779            .as_mut()
780            .expect("current CopperList is missing");
781        current.reset_for_runtime_use(self.next_cl_id);
782        self.next_cl_id += 1;
783        Ok(current.as_mut())
784    }
785
786    #[cfg(feature = "remote-debug")]
787    fn capture_completed_snapshot(&mut self, cl: &CopperList<P>) -> CuResult<()> {
788        self.last_completed_encoded = Some(encode_completed_copperlist_snapshot(cl)?);
789        Ok(())
790    }
791
792    #[cfg(not(feature = "remote-debug"))]
793    fn capture_completed_snapshot(&mut self, _cl: &CopperList<P>) -> CuResult<()> {
794        Ok(())
795    }
796
797    pub fn end_of_processing(&mut self, culistid: u64) -> CuResult<()> {
798        self.reclaim_completed()?;
799
800        let mut culist = self.current.take().ok_or_else(|| {
801            CuError::from("Attempted to finish processing without an active CopperList")
802        })?;
803
804        if culist.id != culistid {
805            return Err(CuError::from(format!(
806                "Attempted to finish CopperList #{culistid} while CopperList #{} is active",
807                culist.id
808            )));
809        }
810        #[cfg(debug_assertions)]
811        debug_assert_processing_completion_state(culist.as_ref(), "async end_of_processing");
812
813        culist.change_state(CopperListState::DoneProcessing);
814        self.capture_completed_snapshot(&culist)?;
815        self.last_encoded_bytes = 0;
816        self.last_handle_bytes = 0;
817
818        if let Some(pending_sender) = &self.pending_sender {
819            culist.change_state(CopperListState::QueuedForSerialization);
820            pending_sender.send(culist).map_err(|e| {
821                CuError::from("Failed to enqueue CopperList for async serialization")
822                    .add_cause(e.to_string().as_str())
823            })?;
824            self.pending_count += 1;
825            self.reclaim_completed()?;
826        } else {
827            culist.change_state(CopperListState::Free);
828            self.free_pool.push(culist);
829        }
830
831        Ok(())
832    }
833
834    pub fn finish_pending(&mut self) -> CuResult<()> {
835        if self.current.is_some() {
836            return Err(CuError::from(
837                "Cannot flush CopperList I/O while a CopperList is still active",
838            ));
839        }
840
841        while self.pending_count > 0 {
842            self.wait_for_completion()?;
843        }
844        Ok(())
845    }
846
847    pub fn available_copper_lists(&mut self) -> CuResult<usize> {
848        self.reclaim_completed()?;
849        Ok(self.free_pool.len())
850    }
851
852    pub fn end_of_processing_boxed(
853        &mut self,
854        mut culist: Box<CopperList<P>>,
855    ) -> CuResult<OwnedCopperListSubmission<P>> {
856        self.reclaim_completed()?;
857        #[cfg(debug_assertions)]
858        debug_assert_processing_completion_state(culist.as_ref(), "async boxed end_of_processing");
859        culist.change_state(CopperListState::DoneProcessing);
860        self.capture_completed_snapshot(&culist)?;
861        self.last_encoded_bytes = 0;
862        self.last_handle_bytes = 0;
863
864        if let Some(pending_sender) = &self.pending_sender {
865            culist.change_state(CopperListState::QueuedForSerialization);
866            pending_sender.send(culist).map_err(|e| {
867                CuError::from("Failed to enqueue CopperList for async serialization")
868                    .add_cause(e.to_string().as_str())
869            })?;
870            self.pending_count += 1;
871            self.reclaim_completed()?;
872            Ok(OwnedCopperListSubmission::Pending)
873        } else {
874            culist.change_state(CopperListState::Free);
875            Ok(OwnedCopperListSubmission::Recycled(culist))
876        }
877    }
878
879    pub fn try_reclaim_boxed(&mut self) -> CuResult<Option<Box<CopperList<P>>>> {
880        let recv_result = {
881            let Some(completion_receiver) = self.completion_receiver.as_ref() else {
882                return Ok(None);
883            };
884            completion_receiver.try_recv()
885        };
886        match recv_result {
887            Ok(completion) => self.handle_completion(completion).map(Some),
888            Err(TryRecvError::Empty) => Ok(None),
889            Err(TryRecvError::Disconnected) => Err(CuError::from(
890                "Async CopperList serializer thread disconnected unexpectedly",
891            )),
892        }
893    }
894
895    pub fn wait_reclaim_boxed(&mut self) -> CuResult<Box<CopperList<P>>> {
896        let completion = self
897            .completion_receiver
898            .as_ref()
899            .ok_or_else(|| {
900                CuError::from("No async CopperList serializer is active to return a free slot")
901            })?
902            .recv()
903            .map_err(|e| {
904                CuError::from("Failed to receive completion from async CopperList serializer")
905                    .add_cause(e.to_string().as_str())
906            })?;
907        self.handle_completion(completion)
908    }
909
910    pub fn finish_pending_boxed(&mut self) -> CuResult<Vec<Box<CopperList<P>>>> {
911        let mut reclaimed = Vec::with_capacity(self.pending_count);
912        if self.current.is_some() {
913            return Err(CuError::from(
914                "Cannot flush CopperList I/O while a CopperList is still active",
915            ));
916        }
917        while self.pending_count > 0 {
918            reclaimed.push(self.wait_reclaim_boxed()?);
919        }
920        Ok(reclaimed)
921    }
922
923    fn reclaim_completed(&mut self) -> CuResult<()> {
924        loop {
925            let Some(culist) = self.try_reclaim_boxed()? else {
926                break;
927            };
928            self.free_pool.push(culist);
929        }
930        Ok(())
931    }
932
933    fn wait_for_completion(&mut self) -> CuResult<()> {
934        let culist = self.wait_reclaim_boxed()?;
935        self.free_pool.push(culist);
936        Ok(())
937    }
938
939    fn handle_completion(
940        &mut self,
941        mut completion: AsyncCopperListCompletion<P>,
942    ) -> CuResult<Box<CopperList<P>>> {
943        self.pending_count = self.pending_count.saturating_sub(1);
944        if let Ok((encoded_bytes, handle_bytes)) = completion.log_result.as_ref() {
945            self.last_encoded_bytes = *encoded_bytes;
946            self.last_handle_bytes = *handle_bytes;
947        }
948        completion.culist.change_state(CopperListState::Free);
949        completion.log_result?;
950        Ok(completion.culist)
951    }
952
953    fn shutdown_worker(&mut self) -> CuResult<()> {
954        self.finish_pending()?;
955        self.pending_sender.take();
956        if let Some(worker_handle) = self.worker_handle.take() {
957            worker_handle.join().map_err(|_| {
958                CuError::from("Async CopperList serializer thread panicked while joining")
959            })?;
960        }
961        Ok(())
962    }
963}
964
965#[cfg(all(feature = "std", feature = "async-cl-io"))]
966impl<P: CopperListTuple + Default, const NBCL: usize> Drop for AsyncCopperListsManager<P, NBCL> {
967    fn drop(&mut self) {
968        let _ = self.shutdown_worker();
969    }
970}
971
972#[cfg(all(feature = "std", debug_assertions))]
973fn debug_assert_processing_completion_state<P: CopperListTuple>(
974    culist: &CopperList<P>,
975    context: &str,
976) {
977    assert_eq!(
978        culist.get_state(),
979        CopperListState::Processing,
980        "{context} expected CopperList #{} to be Processing, found {}",
981        culist.id,
982        culist.get_state()
983    );
984}
985
986#[cfg(all(feature = "std", feature = "async-cl-io"))]
987#[doc(hidden)]
988pub type CopperListsManager<P, const NBCL: usize> = AsyncCopperListsManager<P, NBCL>;
989
990#[cfg(not(all(feature = "std", feature = "async-cl-io")))]
991#[doc(hidden)]
992pub type CopperListsManager<P, const NBCL: usize> = SyncCopperListsManager<P, NBCL>;
993
994/// Manages the frozen tasks state and logging.
995pub struct KeyFramesManager {
996    /// Where the serialized tasks are stored following the wave of execution of a CL.
997    inner: KeyFrame,
998
999    /// Optional override for the timestamp to stamp the next keyframe (used by deterministic replay).
1000    forced_timestamp: Option<CuTime>,
1001
1002    /// If set, reuse this keyframe verbatim (e.g., during replay) instead of re-freezing state.
1003    locked: bool,
1004
1005    /// Logger for the state of the tasks (frozen tasks)
1006    logger: Option<Box<dyn WriteStream<KeyFrame>>>,
1007
1008    /// Capture a keyframe only each...
1009    keyframe_interval: u32,
1010
1011    /// Bytes written by the last keyframe log
1012    pub last_encoded_bytes: u64,
1013
1014    /// Cold-path sizing accumulator used to reserve the capture buffer before execution.
1015    capture_size_hint: usize,
1016}
1017
1018const MIN_KEYFRAME_CAPTURE_CAPACITY: usize = 4 * 1024;
1019
1020/// A `Vec` writer that is forbidden from growing its backing allocation.
1021struct PreallocatedVecWriter<'a>(&'a mut Vec<u8>);
1022
1023impl Writer for PreallocatedVecWriter<'_> {
1024    fn write(&mut self, bytes: &[u8]) -> Result<(), EncodeError> {
1025        if bytes.len() > self.0.capacity().saturating_sub(self.0.len()) {
1026            return Err(EncodeError::UnexpectedEnd);
1027        }
1028        // The capacity check above makes this append allocation-free.
1029        self.0.extend_from_slice(bytes);
1030        Ok(())
1031    }
1032}
1033
1034impl KeyFramesManager {
1035    fn is_keyframe(&self, culistid: u64) -> bool {
1036        self.logger.is_some() && culistid.is_multiple_of(self.keyframe_interval as u64)
1037    }
1038
1039    #[inline]
1040    pub fn captures_keyframe(&self, culistid: u64) -> bool {
1041        self.is_keyframe(culistid)
1042    }
1043
1044    /// Start a cold-path sizing pass for the next mission's keyframe buffer.
1045    #[doc(hidden)]
1046    pub fn begin_capture_preallocation(&mut self) {
1047        self.capture_size_hint = KEYFRAME_PAYLOAD_HEADER.len();
1048    }
1049
1050    /// Include one component's current frozen size in the cold-path capacity estimate.
1051    #[doc(hidden)]
1052    pub fn include_capture_capacity(&mut self, item: &impl Freezable) -> CuResult<()> {
1053        if self.logger.is_none() {
1054            return Ok(());
1055        }
1056        let mut sizer = EncoderImpl::new(SizeWriter::default(), bincode::config::standard());
1057        BincodeAdapter(item)
1058            .encode(&mut sizer)
1059            .map_err(|_| CuError::from("Failed to size component keyframe state"))?;
1060        let payload_bytes = sizer.into_writer().bytes_written as usize;
1061        self.capture_size_hint = self
1062            .capture_size_hint
1063            .checked_add(KEYFRAME_FRAME_HEADER_LEN)
1064            .and_then(|size| size.checked_add(payload_bytes))
1065            .ok_or_else(|| CuError::from("Keyframe capture capacity overflow"))?;
1066        Ok(())
1067    }
1068
1069    /// Reserve the capture buffer before entering the execution loop.
1070    #[doc(hidden)]
1071    pub fn finish_capture_preallocation(&mut self) -> CuResult<()> {
1072        if self.logger.is_none() {
1073            return Ok(());
1074        }
1075        let requested = self
1076            .capture_size_hint
1077            .max(MIN_KEYFRAME_CAPTURE_CAPACITY)
1078            .checked_next_power_of_two()
1079            .ok_or_else(|| CuError::from("Keyframe capture capacity overflow"))?;
1080        if self.inner.serialized_tasks.capacity() < requested {
1081            let additional = requested.saturating_sub(self.inner.serialized_tasks.len());
1082            self.inner
1083                .serialized_tasks
1084                .try_reserve_exact(additional)
1085                .map_err(|error| {
1086                    CuError::from("Failed to preallocate keyframe capture buffer")
1087                        .add_cause(&error.to_string())
1088                })?;
1089        }
1090        Ok(())
1091    }
1092
1093    pub fn reset(&mut self, culistid: u64, clock: &RobotClock) {
1094        if self.is_keyframe(culistid) {
1095            // If a recorded keyframe was preloaded for this CL, keep it as-is.
1096            if self.locked && self.inner.culistid == culistid {
1097                return;
1098            }
1099            let ts = self.forced_timestamp.take().unwrap_or_else(|| clock.now());
1100            self.inner.reset(culistid, ts);
1101            self.locked = false;
1102        }
1103    }
1104
1105    /// Force the timestamp of the next keyframe to a given value.
1106    #[cfg(feature = "std")]
1107    pub fn set_forced_timestamp(&mut self, ts: CuTime) {
1108        self.forced_timestamp = Some(ts);
1109    }
1110
1111    pub fn freeze_task(&mut self, culistid: u64, task: &impl Freezable) -> CuResult<usize> {
1112        if self.is_keyframe(culistid) {
1113            if self.locked {
1114                // We are replaying a recorded keyframe verbatim; don't mutate it.
1115                return Ok(0);
1116            }
1117            if self.inner.culistid != culistid {
1118                return Err(CuError::from(format!(
1119                    "Freezing task for culistid {} but current keyframe is {}",
1120                    culistid, self.inner.culistid
1121                )));
1122            }
1123            let encoded = self
1124                .inner
1125                .add_frozen_task(task)
1126                .map_err(|e| CuError::from(format!("Failed to serialize task: {e}")))?;
1127            Ok(encoded)
1128        } else {
1129            Ok(0)
1130        }
1131    }
1132
1133    /// Generic helper to freeze any `Freezable` state (task or bridge) into the current keyframe.
1134    pub fn freeze_any(&mut self, culistid: u64, item: &impl Freezable) -> CuResult<usize> {
1135        self.freeze_task(culistid, item)
1136    }
1137
1138    pub fn end_of_processing(&mut self, culistid: u64) -> CuResult<()> {
1139        if self.is_keyframe(culistid) {
1140            let logger = self.logger.as_mut().unwrap();
1141            logger.log(&self.inner)?;
1142            self.last_encoded_bytes = logger.last_log_bytes().unwrap_or(0) as u64;
1143            // Clear the lock so the next CL can rebuild normally unless re-locked.
1144            self.locked = false;
1145            Ok(())
1146        } else {
1147            // Not a keyframe for this CL; ensure we don't carry stale sizes forward.
1148            self.last_encoded_bytes = 0;
1149            Ok(())
1150        }
1151    }
1152
1153    /// Preload a recorded keyframe so it is logged verbatim on the matching CL.
1154    #[cfg(feature = "std")]
1155    pub fn lock_keyframe(&mut self, keyframe: &KeyFrame) {
1156        self.inner = keyframe.clone();
1157        self.forced_timestamp = Some(keyframe.timestamp);
1158        self.locked = true;
1159    }
1160}
1161
1162/// This is the main structure that will be injected as a member of the Application struct.
1163/// CT is the tuple of all the tasks in order of execution.
1164/// CL is the type of the copper list, representing the input/output messages for all the tasks.
1165pub struct CuRuntime<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize> {
1166    /// The base clock the runtime will be using to record time.
1167    clock: RobotClock,
1168
1169    /// Compile-time subsystem identity for this Copper process.
1170    subsystem_code: u16,
1171
1172    /// Deployment/runtime instance identity for this Copper process.
1173    #[doc(hidden)]
1174    pub instance_id: u32,
1175
1176    /// The tuple of all the tasks in order of execution.
1177    #[doc(hidden)]
1178    pub tasks: CT,
1179
1180    /// Tuple of all instantiated bridges.
1181    #[doc(hidden)]
1182    pub bridges: CB,
1183
1184    /// Resource registry kept alive for tasks borrowing shared handles.
1185    #[doc(hidden)]
1186    pub resources: ResourceManager,
1187
1188    /// Rayon thread pools owned by the runtime, indexed positionally to
1189    /// `runtime.thread_pools`. Reserved pools (e.g. `"rt"`) leave `None` slots.
1190    #[cfg(feature = "std")]
1191    #[doc(hidden)]
1192    pub thread_pools: Vec<Option<Arc<ThreadPool>>>,
1193
1194    /// The runtime monitoring.
1195    #[doc(hidden)]
1196    pub monitor: M,
1197
1198    /// Runtime-side execution progress probe for watchdog/diagnostic monitors.
1199    ///
1200    /// This probe is written from the generated execution plan before each component
1201    /// step. Monitors consume it asynchronously (typically from watchdog threads) to
1202    /// report the last known component/step/culist when the runtime appears stalled.
1203    #[cfg(feature = "std")]
1204    #[doc(hidden)]
1205    pub execution_probe: ExecutionProbeHandle,
1206    #[cfg(not(feature = "std"))]
1207    #[doc(hidden)]
1208    pub execution_probe: RuntimeExecutionProbe,
1209
1210    /// The logger for the copper lists (messages between tasks)
1211    #[doc(hidden)]
1212    pub copperlists_manager: CopperListsManager<P, NBCL>,
1213
1214    /// The logger for the state of the tasks (frozen tasks)
1215    #[doc(hidden)]
1216    pub keyframes_manager: KeyFramesManager,
1217
1218    /// Feature-gated container for deterministic multi-CopperList execution.
1219    #[cfg(all(feature = "std", feature = "parallel-rt"))]
1220    #[doc(hidden)]
1221    pub parallel_rt: ParallelRt<NBCL>,
1222
1223    /// The runtime configuration controlling the behavior of the run loop
1224    #[doc(hidden)]
1225    pub runtime_config: RuntimeConfig,
1226}
1227
1228/// To be able to share the clock we make the runtime a clock provider.
1229impl<
1230    CT,
1231    CB,
1232    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload,
1233    M: CuMonitor,
1234    const NBCL: usize,
1235> ClockProvider for CuRuntime<CT, CB, P, M, NBCL>
1236{
1237    fn get_clock(&self) -> RobotClock {
1238        self.clock.clone()
1239    }
1240}
1241
1242impl<CT, CB, P: CopperListTuple, M: CuMonitor, const NBCL: usize> CuRuntime<CT, CB, P, M, NBCL> {
1243    /// Returns a clone of the runtime clock handle.
1244    #[inline]
1245    pub fn clock(&self) -> RobotClock {
1246        self.clock.clone()
1247    }
1248
1249    /// Returns the runtime clock by reference for generated runtime code.
1250    #[doc(hidden)]
1251    #[inline]
1252    pub fn clock_ref(&self) -> &RobotClock {
1253        &self.clock
1254    }
1255
1256    /// Returns the compile-time subsystem code for this process.
1257    #[inline]
1258    pub fn subsystem_code(&self) -> u16 {
1259        self.subsystem_code
1260    }
1261
1262    /// Returns the configured runtime instance id for this process.
1263    #[inline]
1264    pub fn instance_id(&self) -> u32 {
1265        self.instance_id
1266    }
1267}
1268
1269#[cfg(feature = "std")]
1270impl<
1271    'cfg,
1272    CT,
1273    CB,
1274    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
1275    M: CuMonitor,
1276    const NBCL: usize,
1277    TI,
1278    BI,
1279    MI,
1280    CLW,
1281    KFW,
1282> CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLW, KFW>
1283where
1284    TI: for<'c> Fn(
1285        Vec<Option<&'c ComponentConfig>>,
1286        &mut ResourceManager,
1287        &[Option<Arc<ThreadPool>>],
1288    ) -> CuResult<CT>,
1289    BI: Fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>,
1290    MI: Fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M,
1291    CLW: WriteStream<CopperList<P>> + 'static,
1292    KFW: WriteStream<KeyFrame> + 'static,
1293{
1294    pub fn build(self) -> CuResult<CuRuntime<CT, CB, P, M, NBCL>> {
1295        let Self {
1296            clock,
1297            config,
1298            mission,
1299            subsystem,
1300            instance_id,
1301            resources,
1302            thread_pools,
1303            parts,
1304            copperlists_logger,
1305            keyframes_logger,
1306        } = self;
1307        let mut resources =
1308            resources.ok_or_else(|| CuError::from("Resources missing from CuRuntimeBuilder"))?;
1309        let thread_pools = thread_pools.unwrap_or_default();
1310
1311        let graph = config.get_graph(Some(mission))?;
1312        let all_instances_configs: Vec<Option<&ComponentConfig>> = graph
1313            .get_all_nodes()
1314            .iter()
1315            .map(|(_, node)| node.get_instance_config())
1316            .collect();
1317
1318        let tasks =
1319            (parts.tasks_instanciator)(all_instances_configs, &mut resources, &thread_pools)?;
1320
1321        #[cfg(feature = "std")]
1322        let execution_probe = std::sync::Arc::new(RuntimeExecutionProbe::default());
1323        #[cfg(not(feature = "std"))]
1324        let execution_probe = RuntimeExecutionProbe::default();
1325        let monitor_metadata = CuMonitoringMetadata::new(
1326            CompactString::from(mission),
1327            parts.monitored_components,
1328            parts.culist_component_mapping,
1329            CopperListInfo::new(core::mem::size_of::<CopperList<P>>(), NBCL),
1330            build_monitor_topology(config, mission)?,
1331            None,
1332        )?
1333        .with_subsystem_id(subsystem.id())
1334        .with_instance_id(instance_id);
1335        #[cfg(feature = "std")]
1336        let monitor_runtime =
1337            CuMonitoringRuntime::new(MonitorExecutionProbe::from_shared(execution_probe.clone()));
1338        #[cfg(not(feature = "std"))]
1339        let monitor_runtime = CuMonitoringRuntime::unavailable();
1340        let monitor = (parts.monitor_instanciator)(config, monitor_metadata, monitor_runtime);
1341        let bridges = (parts.bridges_instanciator)(config, &mut resources)?;
1342
1343        let (copperlists_logger, keyframes_logger, keyframe_interval) = match &config.logging {
1344            Some(logging_config) if logging_config.enable_task_logging => {
1345                let keyframes_logger = logging_config
1346                    .enable_keyframe_logging
1347                    .then(|| Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>);
1348                (
1349                    Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1350                    keyframes_logger,
1351                    logging_config.keyframe_interval.unwrap(),
1352                )
1353            }
1354            Some(_) => (None, None, 0),
1355            None => (
1356                Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1357                Some(Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>),
1358                DEFAULT_KEYFRAME_INTERVAL,
1359            ),
1360        };
1361
1362        let copperlists_manager = CopperListsManager::new(copperlists_logger)?;
1363        #[cfg(target_os = "none")]
1364        {
1365            let cl_size = core::mem::size_of::<CopperList<P>>();
1366            let total_bytes = cl_size.saturating_mul(NBCL);
1367            info!(
1368                "CuRuntimeBuilder: copperlists count={} cl_size={} total_bytes={}",
1369                NBCL, cl_size, total_bytes
1370            );
1371        }
1372
1373        let keyframes_manager = KeyFramesManager {
1374            inner: KeyFrame::new(),
1375            logger: keyframes_logger,
1376            keyframe_interval,
1377            last_encoded_bytes: 0,
1378            forced_timestamp: None,
1379            locked: false,
1380            capture_size_hint: KEYFRAME_PAYLOAD_HEADER.len(),
1381        };
1382        #[cfg(all(feature = "std", feature = "parallel-rt"))]
1383        let parallel_rt = ParallelRt::new(parts.parallel_rt_metadata)?;
1384
1385        let runtime_config = config.runtime.clone().unwrap_or_default();
1386        runtime_config.validate()?;
1387
1388        Ok(CuRuntime {
1389            subsystem_code: subsystem.code(),
1390            instance_id,
1391            tasks,
1392            bridges,
1393            resources,
1394            thread_pools,
1395            monitor,
1396            execution_probe,
1397            clock,
1398            copperlists_manager,
1399            keyframes_manager,
1400            #[cfg(all(feature = "std", feature = "parallel-rt"))]
1401            parallel_rt,
1402            runtime_config,
1403        })
1404    }
1405}
1406
1407#[cfg(not(feature = "std"))]
1408impl<
1409    'cfg,
1410    CT,
1411    CB,
1412    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
1413    M: CuMonitor,
1414    const NBCL: usize,
1415    TI,
1416    BI,
1417    MI,
1418    CLW,
1419    KFW,
1420> CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLW, KFW>
1421where
1422    TI: for<'c> Fn(Vec<Option<&'c ComponentConfig>>, &mut ResourceManager) -> CuResult<CT>,
1423    BI: Fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>,
1424    MI: Fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M,
1425    CLW: WriteStream<CopperList<P>> + 'static,
1426    KFW: WriteStream<KeyFrame> + 'static,
1427{
1428    pub fn build(self) -> CuResult<CuRuntime<CT, CB, P, M, NBCL>> {
1429        let Self {
1430            clock,
1431            config,
1432            mission,
1433            subsystem,
1434            instance_id,
1435            resources,
1436            parts,
1437            copperlists_logger,
1438            keyframes_logger,
1439        } = self;
1440        let mut resources =
1441            resources.ok_or_else(|| CuError::from("Resources missing from CuRuntimeBuilder"))?;
1442
1443        let graph = config.get_graph(Some(mission))?;
1444        let all_instances_configs: Vec<Option<&ComponentConfig>> = graph
1445            .get_all_nodes()
1446            .iter()
1447            .map(|(_, node)| node.get_instance_config())
1448            .collect();
1449
1450        let tasks = (parts.tasks_instanciator)(all_instances_configs, &mut resources)?;
1451
1452        let execution_probe = RuntimeExecutionProbe::default();
1453        let monitor_metadata = CuMonitoringMetadata::new(
1454            CompactString::from(mission),
1455            parts.monitored_components,
1456            parts.culist_component_mapping,
1457            CopperListInfo::new(core::mem::size_of::<CopperList<P>>(), NBCL),
1458            build_monitor_topology(config, mission)?,
1459            None,
1460        )?
1461        .with_subsystem_id(subsystem.id())
1462        .with_instance_id(instance_id);
1463        let monitor_runtime = CuMonitoringRuntime::unavailable();
1464        let monitor = (parts.monitor_instanciator)(config, monitor_metadata, monitor_runtime);
1465        let bridges = (parts.bridges_instanciator)(config, &mut resources)?;
1466
1467        let (copperlists_logger, keyframes_logger, keyframe_interval) = match &config.logging {
1468            Some(logging_config) if logging_config.enable_task_logging => {
1469                let keyframes_logger = logging_config
1470                    .enable_keyframe_logging
1471                    .then(|| Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>);
1472                (
1473                    Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1474                    keyframes_logger,
1475                    logging_config.keyframe_interval.unwrap(),
1476                )
1477            }
1478            Some(_) => (None, None, 0),
1479            None => (
1480                Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1481                Some(Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>),
1482                DEFAULT_KEYFRAME_INTERVAL,
1483            ),
1484        };
1485
1486        let copperlists_manager = CopperListsManager::new(copperlists_logger)?;
1487        #[cfg(target_os = "none")]
1488        {
1489            let cl_size = core::mem::size_of::<CopperList<P>>();
1490            let total_bytes = cl_size.saturating_mul(NBCL);
1491            info!(
1492                "CuRuntimeBuilder: copperlists count={} cl_size={} total_bytes={}",
1493                NBCL, cl_size, total_bytes
1494            );
1495        }
1496
1497        let keyframes_manager = KeyFramesManager {
1498            inner: KeyFrame::new(),
1499            logger: keyframes_logger,
1500            keyframe_interval,
1501            last_encoded_bytes: 0,
1502            forced_timestamp: None,
1503            locked: false,
1504            capture_size_hint: KEYFRAME_PAYLOAD_HEADER.len(),
1505        };
1506
1507        let runtime_config = config.runtime.clone().unwrap_or_default();
1508        runtime_config.validate()?;
1509
1510        Ok(CuRuntime {
1511            subsystem_code: subsystem.code(),
1512            instance_id,
1513            tasks,
1514            bridges,
1515            resources,
1516            monitor,
1517            execution_probe,
1518            clock,
1519            copperlists_manager,
1520            keyframes_manager,
1521            runtime_config,
1522        })
1523    }
1524}
1525
1526/// A keyframe records a distributed snapshot of component state around a copperlist.
1527///
1528/// `serialized_tasks` contains a versioned sequence of length-framed component
1529/// snapshots in their generated execution-wave freeze order.
1530#[derive(Clone, Encode, Decode)]
1531pub struct KeyFrame {
1532    // This is the id of the copper list that this keyframe is associated with (recorded before the copperlist).
1533    pub culistid: u64,
1534    // This is the timestamp when the keyframe was created, using the robot clock.
1535    pub timestamp: CuTime,
1536    // Versioned, length-framed bincode snapshots of all generated components.
1537    pub serialized_tasks: Vec<u8>,
1538}
1539
1540impl KeyFrame {
1541    fn new() -> Self {
1542        KeyFrame {
1543            culistid: 0,
1544            timestamp: CuTime::default(),
1545            serialized_tasks: KEYFRAME_PAYLOAD_HEADER.to_vec(),
1546        }
1547    }
1548
1549    /// This is to be able to avoid reallocations
1550    fn reset(&mut self, culistid: u64, timestamp: CuTime) {
1551        self.culistid = culistid;
1552        self.timestamp = timestamp;
1553        self.serialized_tasks.clear();
1554        self.serialized_tasks
1555            .extend_from_slice(KEYFRAME_PAYLOAD_HEADER);
1556    }
1557
1558    /// Append one length-framed component snapshot in a single `freeze` pass.
1559    fn add_frozen_task(&mut self, task: &impl Freezable) -> Result<usize, EncodeError> {
1560        let cfg = bincode::config::standard();
1561        let start = self.serialized_tasks.len();
1562        let payload_offset =
1563            start
1564                .checked_add(KEYFRAME_FRAME_HEADER_LEN)
1565                .ok_or(EncodeError::Other(
1566                    "keyframe component frame offset overflow",
1567                ))?;
1568        if payload_offset > self.serialized_tasks.capacity() {
1569            return Err(EncodeError::UnexpectedEnd);
1570        }
1571
1572        self.serialized_tasks.resize(payload_offset, 0);
1573        let length_offset = start;
1574        self.serialized_tasks[length_offset..payload_offset].fill(0);
1575
1576        let mut encoder =
1577            EncoderImpl::<_, _>::new(PreallocatedVecWriter(&mut self.serialized_tasks), cfg);
1578        if let Err(error) = BincodeAdapter(task).encode(&mut encoder) {
1579            self.serialized_tasks.truncate(start);
1580            return Err(error);
1581        }
1582        let payload_len = encoder.into_writer().0.len() - payload_offset;
1583        let payload_len = u32::try_from(payload_len).map_err(|_| {
1584            self.serialized_tasks.truncate(start);
1585            EncodeError::OtherString(
1586                "keyframe component snapshot exceeds the u32 frame limit".to_string(),
1587            )
1588        })?;
1589        self.serialized_tasks
1590            .truncate(payload_offset + payload_len as usize);
1591        self.serialized_tasks[length_offset..payload_offset]
1592            .copy_from_slice(&payload_len.to_le_bytes());
1593        Ok(self.serialized_tasks.len() - start)
1594    }
1595}
1596
1597const KEYFRAME_PAYLOAD_MAGIC: &[u8; 4] = b"CUKF";
1598const KEYFRAME_PAYLOAD_VERSION: u8 = 1;
1599const KEYFRAME_PAYLOAD_HEADER: &[u8; 5] = b"CUKF\x01";
1600const KEYFRAME_FRAME_HEADER_LEN: usize = 4;
1601
1602/// Reader for the versioned component frames inside a [`KeyFrame`].
1603#[doc(hidden)]
1604pub struct KeyFramePayloadReader<'a> {
1605    remaining: &'a [u8],
1606}
1607
1608impl<'a> KeyFramePayloadReader<'a> {
1609    /// Validate a keyframe payload and prepare to consume its component frames.
1610    pub fn new(keyframe: &'a KeyFrame) -> CuResult<Self> {
1611        let payload = keyframe.serialized_tasks.as_slice();
1612        if payload.len() < KEYFRAME_PAYLOAD_HEADER.len()
1613            || payload[..KEYFRAME_PAYLOAD_MAGIC.len()] != *KEYFRAME_PAYLOAD_MAGIC
1614        {
1615            return Err(CuError::from(
1616                "Unsupported legacy keyframe payload: expected framed format version 1",
1617            ));
1618        }
1619        let version = payload[KEYFRAME_PAYLOAD_MAGIC.len()];
1620        if version != KEYFRAME_PAYLOAD_VERSION {
1621            return Err(CuError::from(format!(
1622                "Unsupported keyframe payload version {version}; expected {KEYFRAME_PAYLOAD_VERSION}"
1623            )));
1624        }
1625        Ok(Self {
1626            remaining: &payload[KEYFRAME_PAYLOAD_HEADER.len()..],
1627        })
1628    }
1629
1630    /// Consume the next component frame in generated execution order.
1631    pub fn next_frame(&mut self) -> CuResult<&'a [u8]> {
1632        if self.remaining.len() < KEYFRAME_FRAME_HEADER_LEN {
1633            return Err(CuError::from("Keyframe ended before next component frame"));
1634        }
1635        let payload_len = u32::from_le_bytes(
1636            self.remaining[..KEYFRAME_FRAME_HEADER_LEN]
1637                .try_into()
1638                .map_err(|_| CuError::from("Invalid keyframe component frame length"))?,
1639        ) as usize;
1640        let frame_end = KEYFRAME_FRAME_HEADER_LEN
1641            .checked_add(payload_len)
1642            .ok_or_else(|| CuError::from("Keyframe component frame length overflow"))?;
1643        if frame_end > self.remaining.len() {
1644            return Err(CuError::from("Keyframe component frame is truncated"));
1645        }
1646        let payload = &self.remaining[KEYFRAME_FRAME_HEADER_LEN..frame_end];
1647        self.remaining = &self.remaining[frame_end..];
1648        Ok(payload)
1649    }
1650
1651    /// Reject trailing component frames that the generated restore did not consume.
1652    pub fn finish(self) -> CuResult<()> {
1653        if self.remaining.is_empty() {
1654            Ok(())
1655        } else {
1656            Err(CuError::from("Keyframe contains trailing component data"))
1657        }
1658    }
1659}
1660
1661struct FrameSliceReader<'a> {
1662    remaining: &'a [u8],
1663}
1664
1665impl Reader for FrameSliceReader<'_> {
1666    fn read(&mut self, bytes: &mut [u8]) -> Result<(), DecodeError> {
1667        if bytes.len() > self.remaining.len() {
1668            return Err(DecodeError::UnexpectedEnd {
1669                additional: bytes.len() - self.remaining.len(),
1670            });
1671        }
1672        let (read, remaining) = self.remaining.split_at(bytes.len());
1673        bytes.copy_from_slice(read);
1674        self.remaining = remaining;
1675        Ok(())
1676    }
1677
1678    fn peek_read(&mut self, length: usize) -> Option<&[u8]> {
1679        self.remaining.get(..length)
1680    }
1681
1682    fn consume(&mut self, length: usize) {
1683        self.remaining = self.remaining.get(length..).unwrap_or_default();
1684    }
1685}
1686
1687/// Thaw one component from an isolated keyframe frame and require full consumption.
1688#[doc(hidden)]
1689pub fn thaw_keyframe_component(item: &mut impl Freezable, frame: &[u8]) -> CuResult<()> {
1690    let reader = FrameSliceReader { remaining: frame };
1691    let mut decoder = DecoderImpl::new(reader, bincode::config::standard(), ());
1692    item.thaw(&mut decoder)
1693        .map_err(|error| CuError::from(format!("Failed to thaw keyframe component: {error}")))?;
1694    let trailing = decoder.reader().remaining.len();
1695    if trailing != 0 {
1696        return Err(CuError::from(format!(
1697            "Keyframe component snapshot has {} trailing bytes",
1698            trailing
1699        )));
1700    }
1701    Ok(())
1702}
1703
1704/// Identifies where the effective runtime configuration came from.
1705#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1706pub enum RuntimeLifecycleConfigSource {
1707    ProgrammaticOverride,
1708    ExternalFile,
1709    BundledDefault,
1710}
1711
1712/// Stack and process identification metadata persisted in the runtime lifecycle log.
1713#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1714pub struct RuntimeLifecycleStackInfo {
1715    pub app_name: String,
1716    pub app_version: String,
1717    pub git_commit: Option<String>,
1718    pub git_dirty: Option<bool>,
1719    pub subsystem_id: Option<String>,
1720    pub subsystem_code: u16,
1721    pub instance_id: u32,
1722}
1723
1724/// Runtime lifecycle events emitted in the dedicated lifecycle section.
1725#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1726pub enum RuntimeLifecycleEvent {
1727    Instantiated {
1728        config_source: RuntimeLifecycleConfigSource,
1729        effective_config_ron: String,
1730        stack: RuntimeLifecycleStackInfo,
1731    },
1732    MissionStarted {
1733        mission: String,
1734    },
1735    MissionStopped {
1736        mission: String,
1737        // TODO(lifecycle): replace free-form reason with a typed stop reason enum once
1738        // std/no-std behavior and panic integration are split in a follow-up PR.
1739        reason: String,
1740    },
1741    // TODO(lifecycle): wire panic hook / no_std equivalent to emit this event consistently.
1742    Panic {
1743        message: String,
1744        file: Option<String>,
1745        line: Option<u32>,
1746        column: Option<u32>,
1747    },
1748    ShutdownCompleted,
1749}
1750
1751/// One event record persisted in the `UnifiedLogType::RuntimeLifecycle` section.
1752#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1753pub struct RuntimeLifecycleRecord {
1754    pub timestamp: CuTime,
1755    pub event: RuntimeLifecycleEvent,
1756}
1757
1758impl<
1759    CT,
1760    CB,
1761    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
1762    M: CuMonitor,
1763    const NBCL: usize,
1764> CuRuntime<CT, CB, P, M, NBCL>
1765{
1766    /// Records runtime execution progress in the shared probe.
1767    ///
1768    /// This is intentionally lightweight and does not call monitor callbacks.
1769    #[inline]
1770    pub fn record_execution_marker(&self, marker: ExecutionMarker) {
1771        self.execution_probe.record(marker);
1772    }
1773
1774    /// Returns a shared reference to the concrete runtime execution probe.
1775    ///
1776    /// The generated runtime uses this when it needs a uniform
1777    /// `&RuntimeExecutionProbe` view across `std` and `no_std` builds.
1778    #[inline]
1779    pub fn execution_probe_ref(&self) -> &RuntimeExecutionProbe {
1780        #[cfg(feature = "std")]
1781        {
1782            self.execution_probe.as_ref()
1783        }
1784
1785        #[cfg(not(feature = "std"))]
1786        {
1787            &self.execution_probe
1788        }
1789    }
1790}
1791
1792/// Copper tasks can be of 3 types:
1793/// - Source: only producing output messages (usually used for drivers)
1794/// - Regular: processing input messages and producing output messages, more like compute nodes.
1795/// - Sink: only consuming input messages (usually used for actuators)
1796#[derive(Debug, PartialEq, Eq, Clone, Copy)]
1797pub enum CuTaskType {
1798    Source,
1799    Regular,
1800    Sink,
1801}
1802
1803impl From<TaskKind> for CuTaskType {
1804    fn from(value: TaskKind) -> Self {
1805        match value {
1806            TaskKind::Source => CuTaskType::Source,
1807            TaskKind::Regular => CuTaskType::Regular,
1808            TaskKind::Sink => CuTaskType::Sink,
1809        }
1810    }
1811}
1812
1813#[derive(Debug, Clone)]
1814pub struct CuOutputPack {
1815    pub culist_index: u32,
1816    pub msg_types: Vec<String>,
1817}
1818
1819#[derive(Debug, Clone)]
1820pub struct CuInputMsg {
1821    pub culist_index: u32,
1822    pub msg_type: String,
1823    pub src_port: usize,
1824    pub edge_id: usize,
1825    pub connection_order: usize,
1826}
1827
1828/// Which part of its node's job one plan step runs.
1829///
1830/// This is deliberately not folded into [`CuTaskType`]: that enum encodes the
1831/// graph role (Source/Regular/Sink) and drives call-shape decisions everywhere,
1832/// while the phase is orthogonal — an anytime node stays `Regular` and appears
1833/// as one base step plus `max_refines` refine steps (see
1834/// [`expand_anytime_steps`]).
1835#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1836pub enum CuStepPhase {
1837    /// The whole `process()` of a non-anytime task.
1838    #[default]
1839    Whole,
1840    /// The dead-on-arrival age check plus `base()`, at the node's topological
1841    /// position.
1842    AnytimeBase,
1843    /// Exactly one `refine()` quantum.
1844    AnytimeRefine,
1845}
1846
1847/// This structure represents a step in the execution plan.
1848pub struct CuExecutionStep {
1849    /// NodeId: node id of the task to execute
1850    pub node_id: NodeId,
1851    /// Node: node instance
1852    pub node: Node,
1853    /// CuTaskType: type of the task
1854    pub task_type: CuTaskType,
1855    /// Which part of the node's job this step runs (anytime nodes span several
1856    /// steps; everything else is a single `Whole` step).
1857    pub phase: CuStepPhase,
1858
1859    /// the indices in the copper list of the input messages and their types
1860    /// (empty for anytime refine steps: refinement reads no input)
1861    pub input_msg_indices_types: Vec<CuInputMsg>,
1862
1863    /// the index in the copper list of the output message and its type
1864    /// (an anytime node's refine steps carry the same pack as its base step)
1865    pub output_msg_pack: Option<CuOutputPack>,
1866}
1867
1868impl Debug for CuExecutionStep {
1869    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
1870        f.write_str(format!("   CuExecutionStep: Node Id: {}\n", self.node_id).as_str())?;
1871        f.write_str(format!("                  task_type: {:?}\n", self.node.get_type()).as_str())?;
1872        f.write_str(format!("                       task: {:?}\n", self.task_type).as_str())?;
1873        f.write_str(format!("                      phase: {:?}\n", self.phase).as_str())?;
1874        f.write_str(
1875            format!(
1876                "              input_msg_types: {:?}\n",
1877                self.input_msg_indices_types
1878            )
1879            .as_str(),
1880        )?;
1881        f.write_str(format!("       output_msg_pack: {:?}\n", self.output_msg_pack).as_str())?;
1882        Ok(())
1883    }
1884}
1885
1886/// This structure represents a loop in the execution plan.
1887/// It is used to represent a sequence of Execution units (loop or steps) that are executed
1888/// multiple times.
1889/// if loop_count is None, the loop is infinite.
1890pub struct CuExecutionLoop {
1891    pub steps: Vec<CuExecutionUnit>,
1892    pub loop_count: Option<u32>,
1893}
1894
1895impl Debug for CuExecutionLoop {
1896    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
1897        f.write_str("CuExecutionLoop:\n")?;
1898        for step in &self.steps {
1899            match step {
1900                CuExecutionUnit::Step(step) => {
1901                    step.fmt(f)?;
1902                }
1903                CuExecutionUnit::Loop(l) => {
1904                    l.fmt(f)?;
1905                }
1906            }
1907        }
1908
1909        f.write_str(format!("   count: {:?}", self.loop_count).as_str())?;
1910        Ok(())
1911    }
1912}
1913
1914/// This structure represents a step in the execution plan.
1915#[derive(Debug)]
1916pub enum CuExecutionUnit {
1917    Step(Box<CuExecutionStep>),
1918    Loop(CuExecutionLoop),
1919}
1920
1921pub fn find_task_type_for_id(graph: &CuGraph, node_id: NodeId) -> CuResult<CuTaskType> {
1922    let node = graph
1923        .get_node(node_id)
1924        .ok_or_else(|| CuError::from(format!("Node id {node_id} not found")))?;
1925
1926    if node.get_flavor() == crate::config::Flavor::Task {
1927        return resolve_task_kind_for_id(graph, node_id).map(Into::into);
1928    }
1929
1930    let has_inputs = !graph.get_dst_edges(node_id)?.is_empty();
1931    let has_outputs = !graph.get_src_edges(node_id)?.is_empty();
1932    Ok(match (has_inputs, has_outputs) {
1933        (false, true) => CuTaskType::Source,
1934        (true, false) => CuTaskType::Sink,
1935        _ => CuTaskType::Regular,
1936    })
1937}
1938
1939/// Compute the default (`Linearity`) execution plan for `graph`.
1940///
1941/// The plan is now pluggable: this splits into the shared
1942/// `order` + `check_order` + `plan_from_order` pipeline in `planner`, kept here
1943/// so direct callers (tests, tooling) keep a one-call entry point.
1944pub fn compute_runtime_plan(graph: &CuGraph) -> CuResult<CuExecutionLoop> {
1945    let order = Linearity.plan(graph)?;
1946    check_order(graph, &order)?;
1947    plan_from_order(graph, &order)
1948}
1949
1950/// Expands every foreground anytime node of an already-computed plan into its
1951/// chunked steps.
1952///
1953/// The node's single `Whole` step becomes an [`CuStepPhase::AnytimeBase`] step
1954/// at its topological position, and `max_refines` [`CuStepPhase::AnytimeRefine`]
1955/// steps (one `refine()` quantum each) are woven between it and the earliest
1956/// step consuming the node's output: one immediately after the base step, one
1957/// after each subsequent independent step, and the remainder contiguously
1958/// before the consumer. If `max_refines` is smaller than the gap, later gap
1959/// steps get no quantum between them; if the node has no consumer in this
1960/// plan, every refine step sits right after the base step.
1961///
1962/// The refine count must be known here — the emission count *is* the iteration
1963/// bound — which is why `max_refines` is mandatory for foreground anytime
1964/// nodes. How many quanta run and where they sit between other steps is
1965/// entirely this compile-time scheduling decision; the generated code carries
1966/// no counter.
1967pub fn expand_anytime_steps(plan: &mut CuExecutionLoop) -> CuResult<()> {
1968    loop {
1969        // One node at a time: expanded steps get a non-`Whole` phase, so the
1970        // scan converges even though insertions shift positions.
1971        let Some(base_pos) = plan.steps.iter().position(|unit| {
1972            matches!(
1973                unit,
1974                CuExecutionUnit::Step(step) if step.phase == CuStepPhase::Whole
1975                    && step.node.anytime().is_some()
1976                    && !step.node.is_background()
1977            )
1978        }) else {
1979            return Ok(());
1980        };
1981
1982        let CuExecutionUnit::Step(base_step) = &mut plan.steps[base_pos] else {
1983            unreachable!("position() only matches steps");
1984        };
1985        let anytime = base_step
1986            .node
1987            .anytime()
1988            .expect("position() only matches anytime nodes");
1989        // Defense in depth for direct API callers: the macro pipeline rejects
1990        // this at configuration time (config.rs validate_anytime_graph).
1991        let Some(max_refines) = anytime.max_refines else {
1992            return Err(CuError::from(format!(
1993                "Task '{}': a foreground anytime task needs anytime.max_refines to expand into a static plan.",
1994                base_step.node.get_id()
1995            )));
1996        };
1997        base_step.phase = CuStepPhase::AnytimeBase;
1998        let output_pack = base_step.output_msg_pack.clone().ok_or_else(|| {
1999            CuError::from(format!(
2000                "Task '{}': an anytime task needs an output to refine.",
2001                base_step.node.get_id()
2002            ))
2003        })?;
2004        let output_index = output_pack.culist_index;
2005        let node_id = base_step.node_id;
2006        let node = base_step.node.clone();
2007        let task_type = base_step.task_type;
2008
2009        let refine_step = || {
2010            CuExecutionUnit::Step(Box::new(CuExecutionStep {
2011                node_id,
2012                node: node.clone(),
2013                task_type,
2014                phase: CuStepPhase::AnytimeRefine,
2015                input_msg_indices_types: Vec::new(),
2016                output_msg_pack: Some(output_pack.clone()),
2017            }))
2018        };
2019
2020        // Earliest step consuming the node's output; refine steps never match
2021        // (their inputs are empty), so already-expanded nodes stay inert here.
2022        let consumer_pos = plan.steps[base_pos + 1..]
2023            .iter()
2024            .position(|unit| {
2025                matches!(
2026                    unit,
2027                    CuExecutionUnit::Step(step) if step
2028                        .input_msg_indices_types
2029                        .iter()
2030                        .any(|input| input.culist_index == output_index)
2031                )
2032            })
2033            .map(|offset| base_pos + 1 + offset)
2034            .unwrap_or(base_pos + 1);
2035
2036        let mut tail = plan.steps.split_off(base_pos + 1);
2037        let suffix = tail.split_off(consumer_pos - base_pos - 1);
2038        let gap = tail;
2039
2040        // max_refines >= 1 is enforced by AnytimeConfig::validate.
2041        let mut remaining = max_refines.max(1);
2042        remaining -= 1;
2043        plan.steps.push(refine_step());
2044        for gap_unit in gap {
2045            plan.steps.push(gap_unit);
2046            if remaining > 0 {
2047                remaining -= 1;
2048                plan.steps.push(refine_step());
2049            }
2050        }
2051        for _ in 0..remaining {
2052            plan.steps.push(refine_step());
2053        }
2054        plan.steps.extend(suffix);
2055    }
2056}
2057
2058//tests
2059#[cfg(test)]
2060mod tests {
2061    use super::*;
2062    use crate::config::Node;
2063    use crate::context::CuContext;
2064    use crate::cutask::CuSinkTask;
2065    use crate::cutask::{CuSrcTask, Freezable};
2066    use crate::monitoring::NoMonitor;
2067    use crate::reflect::Reflect;
2068    use bincode::Encode;
2069    use core::cell::Cell;
2070    use cu29_traits::{ErasedCuStampedData, ErasedCuStampedDataSet, MatchingTasks};
2071    use serde_derive::{Deserialize, Serialize};
2072    #[cfg(feature = "std")]
2073    use std::sync::{Arc, Mutex};
2074
2075    struct CountingSnapshot<'a> {
2076        calls: &'a Cell<usize>,
2077        value: u32,
2078        fail: bool,
2079    }
2080
2081    impl Freezable for CountingSnapshot<'_> {
2082        fn freeze<E: bincode::enc::Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
2083            self.calls.set(self.calls.get() + 1);
2084            self.value.encode(encoder)?;
2085            if self.fail {
2086                Err(EncodeError::OtherString(
2087                    "intentional freeze failure".to_string(),
2088                ))
2089            } else {
2090                Ok(())
2091            }
2092        }
2093    }
2094
2095    #[derive(Default)]
2096    struct SnapshotValue(u32);
2097
2098    impl Freezable for SnapshotValue {
2099        fn thaw<D: bincode::de::Decoder>(&mut self, decoder: &mut D) -> Result<(), DecodeError> {
2100            self.0 = u32::decode(decoder)?;
2101            Ok(())
2102        }
2103    }
2104
2105    #[test]
2106    fn keyframe_frames_freeze_once_and_roll_back_only_failed_frame() {
2107        let calls = Cell::new(0);
2108        let mut keyframe = KeyFrame::new();
2109        keyframe
2110            .serialized_tasks
2111            .try_reserve_exact(MIN_KEYFRAME_CAPTURE_CAPACITY)
2112            .unwrap();
2113        keyframe.reset(7, CuTime::from_nanos(70));
2114        keyframe
2115            .add_frozen_task(&CountingSnapshot {
2116                calls: &calls,
2117                value: 11,
2118                fail: false,
2119            })
2120            .unwrap();
2121        let committed_len = keyframe.serialized_tasks.len();
2122
2123        let failing = CountingSnapshot {
2124            calls: &calls,
2125            value: 99,
2126            fail: true,
2127        };
2128        assert!(keyframe.add_frozen_task(&failing).is_err());
2129        assert_eq!(keyframe.serialized_tasks.len(), committed_len);
2130
2131        keyframe
2132            .add_frozen_task(&CountingSnapshot {
2133                calls: &calls,
2134                value: 22,
2135                fail: false,
2136            })
2137            .unwrap();
2138        assert_eq!(calls.get(), 3, "each append must call freeze exactly once");
2139        let first_payload_len = bincode::encode_to_vec(11u32, bincode::config::standard())
2140            .unwrap()
2141            .len();
2142        let second_payload_len = bincode::encode_to_vec(22u32, bincode::config::standard())
2143            .unwrap()
2144            .len();
2145        assert_eq!(
2146            keyframe.serialized_tasks.len(),
2147            KEYFRAME_PAYLOAD_HEADER.len()
2148                + 2 * KEYFRAME_FRAME_HEADER_LEN
2149                + first_payload_len
2150                + second_payload_len,
2151            "component frames carry only a length prefix"
2152        );
2153
2154        let mut frames = KeyFramePayloadReader::new(&keyframe).unwrap();
2155        let mut first = SnapshotValue::default();
2156        thaw_keyframe_component(&mut first, frames.next_frame().unwrap()).unwrap();
2157        let mut second = SnapshotValue::default();
2158        thaw_keyframe_component(&mut second, frames.next_frame().unwrap()).unwrap();
2159        frames.finish().unwrap();
2160        assert_eq!((first.0, second.0), (11, 22));
2161    }
2162
2163    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2164    #[test]
2165    fn preallocated_keyframe_append_does_not_allocate() {
2166        let calls = Cell::new(0);
2167        let mut keyframe = KeyFrame::new();
2168        keyframe
2169            .serialized_tasks
2170            .try_reserve_exact(MIN_KEYFRAME_CAPTURE_CAPACITY)
2171            .unwrap();
2172        keyframe.reset(3, CuTime::from_nanos(30));
2173
2174        let allocations = crate::monitoring::ScopedAllocCounter::new();
2175        keyframe
2176            .add_frozen_task(&CountingSnapshot {
2177                calls: &calls,
2178                value: 42,
2179                fail: false,
2180            })
2181            .unwrap();
2182
2183        assert_eq!(allocations.allocated(), 0);
2184        assert_eq!(calls.get(), 1);
2185    }
2186
2187    #[test]
2188    fn keyframe_reader_rejects_legacy_payload_clearly() {
2189        let keyframe = KeyFrame {
2190            culistid: 0,
2191            timestamp: CuTime::default(),
2192            serialized_tasks: vec![0, 1, 2],
2193        };
2194        let error = match KeyFramePayloadReader::new(&keyframe) {
2195            Ok(_) => panic!("legacy payload unexpectedly accepted"),
2196            Err(error) => error,
2197        };
2198        assert!(error.to_string().contains("legacy keyframe payload"));
2199    }
2200
2201    #[derive(Reflect)]
2202    pub struct TestSource {}
2203
2204    impl Freezable for TestSource {}
2205
2206    impl CuSrcTask for TestSource {
2207        type Resources<'r> = ();
2208        type Output<'m> = ();
2209        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
2210        where
2211            Self: Sized,
2212        {
2213            Ok(Self {})
2214        }
2215
2216        fn process(&mut self, _ctx: &CuContext, _empty_msg: &mut Self::Output<'_>) -> CuResult<()> {
2217            Ok(())
2218        }
2219    }
2220
2221    #[derive(Reflect)]
2222    pub struct TestSink {}
2223
2224    impl Freezable for TestSink {}
2225
2226    impl CuSinkTask for TestSink {
2227        type Resources<'r> = ();
2228        type Input<'m> = ();
2229
2230        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
2231        where
2232            Self: Sized,
2233        {
2234            Ok(Self {})
2235        }
2236
2237        fn process(&mut self, _ctx: &CuContext, _input: &Self::Input<'_>) -> CuResult<()> {
2238            Ok(())
2239        }
2240    }
2241
2242    // Those should be generated by the derive macro
2243    type Tasks = (TestSource, TestSink);
2244    type TestRuntime = CuRuntime<Tasks, (), Msgs, NoMonitor, 2>;
2245    const TEST_NBCL: usize = 2;
2246
2247    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
2248    struct Msgs(());
2249
2250    impl ErasedCuStampedDataSet for Msgs {
2251        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
2252            Vec::new()
2253        }
2254    }
2255
2256    impl MatchingTasks for Msgs {
2257        fn get_all_task_ids() -> &'static [&'static str] {
2258            &[]
2259        }
2260    }
2261
2262    impl CuListZeroedInit for Msgs {
2263        fn init_zeroed(&mut self) {}
2264    }
2265
2266    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
2267    struct IntMsgs(i32);
2268
2269    impl ErasedCuStampedDataSet for IntMsgs {
2270        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
2271            Vec::new()
2272        }
2273    }
2274
2275    impl MatchingTasks for IntMsgs {
2276        fn get_all_task_ids() -> &'static [&'static str] {
2277            &[]
2278        }
2279    }
2280
2281    impl CuListZeroedInit for IntMsgs {
2282        fn init_zeroed(&mut self) {}
2283    }
2284
2285    #[cfg(feature = "std")]
2286    fn tasks_instanciator(
2287        all_instances_configs: Vec<Option<&ComponentConfig>>,
2288        _resources: &mut ResourceManager,
2289        _thread_pools: &[Option<Arc<rayon::ThreadPool>>],
2290    ) -> CuResult<Tasks> {
2291        Ok((
2292            TestSource::new(all_instances_configs[0], ())?,
2293            TestSink::new(all_instances_configs[1], ())?,
2294        ))
2295    }
2296
2297    #[cfg(not(feature = "std"))]
2298    fn tasks_instanciator(
2299        all_instances_configs: Vec<Option<&ComponentConfig>>,
2300        _resources: &mut ResourceManager,
2301    ) -> CuResult<Tasks> {
2302        Ok((
2303            TestSource::new(all_instances_configs[0], ())?,
2304            TestSink::new(all_instances_configs[1], ())?,
2305        ))
2306    }
2307
2308    fn monitor_instanciator(
2309        _config: &CuConfig,
2310        metadata: CuMonitoringMetadata,
2311        runtime: CuMonitoringRuntime,
2312    ) -> NoMonitor {
2313        NoMonitor::new(metadata, runtime).expect("NoMonitor::new should never fail")
2314    }
2315
2316    fn bridges_instanciator(_config: &CuConfig, _resources: &mut ResourceManager) -> CuResult<()> {
2317        Ok(())
2318    }
2319
2320    fn resources_instanciator(_config: &CuConfig) -> CuResult<ResourceManager> {
2321        Ok(ResourceManager::new(&[]))
2322    }
2323
2324    #[derive(Debug)]
2325    struct FakeWriter {}
2326
2327    impl<E: Encode> WriteStream<E> for FakeWriter {
2328        fn log(&mut self, _obj: &E) -> CuResult<()> {
2329            Ok(())
2330        }
2331    }
2332
2333    #[cfg(not(feature = "async-cl-io"))]
2334    #[derive(Debug)]
2335    struct RecordingSyncWriter {
2336        ids: Arc<Mutex<Vec<u64>>>,
2337        last_log_bytes: usize,
2338        fail_on: Option<u64>,
2339    }
2340
2341    #[cfg(not(feature = "async-cl-io"))]
2342    impl WriteStream<CopperList<IntMsgs>> for RecordingSyncWriter {
2343        fn log(&mut self, culist: &CopperList<IntMsgs>) -> CuResult<()> {
2344            self.ids.lock().unwrap().push(culist.id);
2345            if self.fail_on == Some(culist.id) {
2346                return Err(CuError::from(format!(
2347                    "logger failed for CopperList #{}",
2348                    culist.id
2349                )));
2350            }
2351            Ok(())
2352        }
2353
2354        fn last_log_bytes(&self) -> Option<usize> {
2355            Some(self.last_log_bytes)
2356        }
2357    }
2358
2359    #[test]
2360    fn test_runtime_instantiation() {
2361        let mut config = CuConfig::default();
2362        let graph = config.get_graph_mut(None).unwrap();
2363        graph.add_node(Node::new("a", "TestSource")).unwrap();
2364        graph.add_node(Node::new("b", "TestSink")).unwrap();
2365        graph.connect(0, 1, "()").unwrap();
2366        let runtime: CuResult<TestRuntime> =
2367            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
2368                RobotClock::default(),
2369                &config,
2370                crate::config::DEFAULT_MISSION_ID,
2371                CuRuntimeParts::new(
2372                    tasks_instanciator,
2373                    &[],
2374                    &[],
2375                    #[cfg(all(feature = "std", feature = "parallel-rt"))]
2376                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
2377                    monitor_instanciator,
2378                    bridges_instanciator,
2379                ),
2380                FakeWriter {},
2381                FakeWriter {},
2382            )
2383            .try_with_resources_instantiator(resources_instanciator)
2384            .and_then(|builder| builder.build());
2385        assert!(runtime.is_ok());
2386    }
2387
2388    #[test]
2389    fn test_rate_target_period_rejects_zero() {
2390        let err = rate_target_period(0).expect_err("zero rate target should fail");
2391        assert!(
2392            err.to_string()
2393                .contains("Runtime rate target cannot be zero"),
2394            "unexpected error: {err}"
2395        );
2396    }
2397
2398    #[test]
2399    fn test_loop_rate_limiter_advances_to_next_period_when_on_time() {
2400        let (clock, mock) = RobotClock::mock();
2401        let mut limiter = LoopRateLimiter::from_rate_target_hz(100, &clock).unwrap();
2402        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(10_000_000));
2403
2404        mock.set_value(10_000_000);
2405        limiter.mark_tick(&clock);
2406
2407        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(20_000_000));
2408    }
2409
2410    #[test]
2411    fn test_loop_rate_limiter_skips_missed_periods_without_resetting_phase() {
2412        let (clock, mock) = RobotClock::mock();
2413        let mut limiter = LoopRateLimiter::from_rate_target_hz(100, &clock).unwrap();
2414
2415        mock.set_value(35_000_000);
2416        limiter.mark_tick(&clock);
2417
2418        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(40_000_000));
2419    }
2420
2421    #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
2422    #[test]
2423    fn test_loop_rate_limiter_spin_window_is_fixed_scheduler_window() {
2424        let (clock, _) = RobotClock::mock();
2425        let limiter = LoopRateLimiter::from_rate_target_hz(1_000, &clock).unwrap();
2426        assert_eq!(limiter.spin_window(), CuDuration::from(200_000));
2427
2428        let fast = LoopRateLimiter::from_rate_target_hz(10_000, &clock).unwrap();
2429        assert_eq!(fast.spin_window(), CuDuration::from(200_000));
2430    }
2431
2432    #[cfg(not(feature = "async-cl-io"))]
2433    #[test]
2434    fn test_copperlists_manager_lifecycle() {
2435        let mut config = CuConfig::default();
2436        let graph = config.get_graph_mut(None).unwrap();
2437        graph.add_node(Node::new("a", "TestSource")).unwrap();
2438        graph.add_node(Node::new("b", "TestSink")).unwrap();
2439        graph.connect(0, 1, "()").unwrap();
2440
2441        let mut runtime: TestRuntime =
2442            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
2443                RobotClock::default(),
2444                &config,
2445                crate::config::DEFAULT_MISSION_ID,
2446                CuRuntimeParts::new(
2447                    tasks_instanciator,
2448                    &[],
2449                    &[],
2450                    #[cfg(all(feature = "std", feature = "parallel-rt"))]
2451                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
2452                    monitor_instanciator,
2453                    bridges_instanciator,
2454                ),
2455                FakeWriter {},
2456                FakeWriter {},
2457            )
2458            .try_with_resources_instantiator(resources_instanciator)
2459            .and_then(|builder| builder.build())
2460            .unwrap();
2461
2462        // Now emulates the generated runtime
2463        {
2464            let copperlists = &mut runtime.copperlists_manager;
2465            let culist0 = copperlists
2466                .create()
2467                .expect("Ran out of space for copper lists");
2468            let id = culist0.id;
2469            assert_eq!(id, 0);
2470            culist0.change_state(CopperListState::Processing);
2471            assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2472        }
2473
2474        {
2475            let copperlists = &mut runtime.copperlists_manager;
2476            let culist1 = copperlists
2477                .create()
2478                .expect("Ran out of space for copper lists");
2479            let id = culist1.id;
2480            assert_eq!(id, 1);
2481            culist1.change_state(CopperListState::Processing);
2482            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2483        }
2484
2485        {
2486            let copperlists = &mut runtime.copperlists_manager;
2487            let culist2 = copperlists.create();
2488            assert!(culist2.is_err());
2489            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2490            // Free in order, should let the top of the stack be serialized and freed.
2491            let _ = copperlists.end_of_processing(1);
2492            assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2493        }
2494
2495        // Readd a CL
2496        {
2497            let copperlists = &mut runtime.copperlists_manager;
2498            let culist2 = copperlists
2499                .create()
2500                .expect("Ran out of space for copper lists");
2501            let id = culist2.id;
2502            assert_eq!(id, 2);
2503            culist2.change_state(CopperListState::Processing);
2504            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2505            // Free out of order, the #0 first
2506            let _ = copperlists.end_of_processing(0);
2507            // Should not free up the top of the stack
2508            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2509
2510            // Free up the top of the stack
2511            let _ = copperlists.end_of_processing(2);
2512            // This should free up 2 CLs
2513
2514            assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
2515        }
2516    }
2517
2518    #[cfg(not(feature = "async-cl-io"))]
2519    #[test]
2520    fn test_sync_copperlists_accessors_passthrough_to_inner_manager() {
2521        let mut copperlists = SyncCopperListsManager::<IntMsgs, 2>::new(None).unwrap();
2522
2523        assert_eq!(copperlists.next_cl_id(), 0);
2524        assert_eq!(copperlists.last_cl_id(), 0);
2525        assert!(copperlists.peek().is_none());
2526
2527        {
2528            let culist = copperlists.create().unwrap();
2529            culist.msgs.0 = 11;
2530            assert_eq!(culist.id, 0);
2531            assert_eq!(culist.get_state(), CopperListState::Initialized);
2532        }
2533
2534        assert_eq!(copperlists.next_cl_id(), 1);
2535        assert_eq!(copperlists.last_cl_id(), 0);
2536        let peeked = copperlists.peek().unwrap();
2537        assert_eq!(peeked.id, 0);
2538        assert_eq!(peeked.msgs.0, 11);
2539        assert_eq!(peeked.get_state(), CopperListState::Initialized);
2540    }
2541
2542    #[cfg(not(feature = "async-cl-io"))]
2543    #[test]
2544    fn test_sync_reclaimed_slot_reuse_reinitializes_state_but_preserves_payload_storage() {
2545        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2546
2547        {
2548            let culist = copperlists.create().unwrap();
2549            culist.msgs.0 = 41;
2550            culist.change_state(CopperListState::Processing);
2551            assert_eq!(culist.id, 0);
2552        }
2553
2554        copperlists.end_of_processing(0).unwrap();
2555        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2556
2557        let reused = copperlists.create().unwrap();
2558        assert_eq!(reused.id, 1);
2559        assert_eq!(reused.get_state(), CopperListState::Initialized);
2560        assert_eq!(reused.msgs.0, 41);
2561    }
2562
2563    #[cfg(all(not(feature = "async-cl-io"), debug_assertions))]
2564    #[test]
2565    #[should_panic(expected = "sync end_of_processing expected exactly one active CopperList #99")]
2566    fn test_sync_end_of_processing_unknown_id_panics_in_debug() {
2567        let mut copperlists = SyncCopperListsManager::<IntMsgs, 2>::new(None).unwrap();
2568
2569        {
2570            let culist = copperlists.create().unwrap();
2571            culist.msgs.0 = 10;
2572            culist.change_state(CopperListState::Processing);
2573        }
2574        {
2575            let culist = copperlists.create().unwrap();
2576            culist.msgs.0 = 20;
2577            culist.change_state(CopperListState::Processing);
2578        }
2579
2580        let _ = copperlists.end_of_processing(99);
2581    }
2582
2583    #[cfg(all(not(feature = "async-cl-io"), debug_assertions))]
2584    #[test]
2585    #[should_panic(expected = "sync end_of_processing expected CopperList #0 to be Processing")]
2586    fn test_sync_end_of_processing_wrong_state_panics_in_debug() {
2587        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2588
2589        {
2590            let culist = copperlists.create().unwrap();
2591            culist.msgs.0 = 10;
2592            assert_eq!(culist.get_state(), CopperListState::Initialized);
2593        }
2594
2595        let _ = copperlists.end_of_processing(0);
2596    }
2597
2598    #[cfg(not(feature = "async-cl-io"))]
2599    #[test]
2600    fn test_sync_end_of_processing_serializes_done_suffix_from_newest_to_oldest() {
2601        let ids = Arc::new(Mutex::new(Vec::new()));
2602        let mut copperlists =
2603            SyncCopperListsManager::<IntMsgs, 2>::new(Some(Box::new(RecordingSyncWriter {
2604                ids: ids.clone(),
2605                last_log_bytes: 17,
2606                fail_on: None,
2607            })))
2608            .unwrap();
2609
2610        {
2611            let culist = copperlists.create().unwrap();
2612            culist.msgs.0 = 10;
2613            culist.change_state(CopperListState::Processing);
2614        }
2615        {
2616            let culist = copperlists.create().unwrap();
2617            culist.msgs.0 = 20;
2618            culist.change_state(CopperListState::Processing);
2619        }
2620
2621        copperlists.end_of_processing(0).unwrap();
2622        assert!(ids.lock().unwrap().is_empty());
2623        assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2624
2625        copperlists.end_of_processing(1).unwrap();
2626
2627        assert_eq!(*ids.lock().unwrap(), vec![1, 0]);
2628        assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
2629    }
2630
2631    #[cfg(not(feature = "async-cl-io"))]
2632    #[test]
2633    fn test_sync_end_of_processing_updates_logger_counters_on_success() {
2634        let ids = Arc::new(Mutex::new(Vec::new()));
2635        let mut copperlists =
2636            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSyncWriter {
2637                ids: ids.clone(),
2638                last_log_bytes: 17,
2639                fail_on: None,
2640            })))
2641            .unwrap();
2642        let io_cache = crate::monitoring::CuMsgIoCache::<1>::default();
2643
2644        {
2645            let culist = copperlists.create().unwrap();
2646            culist.msgs.0 = 10;
2647            culist.change_state(CopperListState::Processing);
2648        }
2649
2650        {
2651            let capture = crate::monitoring::start_copperlist_io_capture(&io_cache);
2652            capture.select_slot(0);
2653            crate::monitoring::record_payload_handle_bytes(32);
2654        }
2655
2656        copperlists.end_of_processing(0).unwrap();
2657
2658        assert_eq!(*ids.lock().unwrap(), vec![0]);
2659        assert_eq!(copperlists.last_encoded_bytes, 17);
2660        assert_eq!(copperlists.last_handle_bytes, 32);
2661        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2662    }
2663
2664    #[cfg(not(feature = "async-cl-io"))]
2665    #[test]
2666    fn test_sync_end_of_processing_preserves_slot_on_logger_error() {
2667        let ids = Arc::new(Mutex::new(Vec::new()));
2668        let mut copperlists =
2669            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSyncWriter {
2670                ids: ids.clone(),
2671                last_log_bytes: 17,
2672                fail_on: Some(0),
2673            })))
2674            .unwrap();
2675
2676        {
2677            let culist = copperlists.create().unwrap();
2678            culist.change_state(CopperListState::Processing);
2679        }
2680
2681        let err = copperlists.end_of_processing(0).unwrap_err();
2682
2683        assert!(
2684            err.to_string().contains("logger failed for CopperList #0"),
2685            "unexpected error: {err}"
2686        );
2687        assert_eq!(*ids.lock().unwrap(), vec![0]);
2688        assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2689        assert_eq!(copperlists.last_encoded_bytes, 0);
2690        assert_eq!(copperlists.last_handle_bytes, 0);
2691
2692        let peeked = copperlists.peek().unwrap();
2693        assert_eq!(peeked.id, 0);
2694        assert_eq!(peeked.get_state(), CopperListState::BeingSerialized);
2695    }
2696
2697    #[cfg(all(not(feature = "async-cl-io"), feature = "std", debug_assertions))]
2698    #[test]
2699    #[should_panic(
2700        expected = "sync boxed end_of_processing expected CopperList #7 to be Processing"
2701    )]
2702    fn test_sync_end_of_processing_boxed_wrong_state_panics_in_debug() {
2703        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2704        let culist = Box::new(CopperList::new(7, IntMsgs::default()));
2705
2706        let _ = copperlists.end_of_processing_boxed(culist);
2707    }
2708
2709    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2710    #[derive(Debug, Default)]
2711    struct RecordingWriter {
2712        ids: Arc<Mutex<Vec<u64>>>,
2713    }
2714
2715    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2716    impl WriteStream<CopperList<Msgs>> for RecordingWriter {
2717        fn log(&mut self, culist: &CopperList<Msgs>) -> CuResult<()> {
2718            self.ids.lock().unwrap().push(culist.id);
2719            std::thread::sleep(std::time::Duration::from_millis(2));
2720            Ok(())
2721        }
2722    }
2723
2724    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2725    #[test]
2726    fn test_async_copperlists_manager_flushes_in_order() {
2727        let ids = Arc::new(Mutex::new(Vec::new()));
2728        let mut copperlists = CopperListsManager::<Msgs, 4>::new(Some(Box::new(RecordingWriter {
2729            ids: ids.clone(),
2730        })))
2731        .unwrap();
2732
2733        for expected_id in 0..4 {
2734            let culist = copperlists.create().unwrap();
2735            assert_eq!(culist.id, expected_id);
2736            culist.change_state(CopperListState::Processing);
2737            copperlists.end_of_processing(expected_id).unwrap();
2738        }
2739
2740        copperlists.finish_pending().unwrap();
2741        assert_eq!(copperlists.available_copper_lists().unwrap(), 4);
2742        assert_eq!(*ids.lock().unwrap(), vec![0, 1, 2, 3]);
2743    }
2744
2745    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2746    #[test]
2747    fn test_async_create_reinitializes_reclaimed_slot_state_but_preserves_payload_storage() {
2748        let mut copperlists = CopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2749
2750        {
2751            let culist = copperlists.create().unwrap();
2752            assert_eq!(culist.id, 0);
2753            assert_eq!(culist.get_state(), CopperListState::Initialized);
2754            culist.msgs.0 = 41;
2755            culist.change_state(CopperListState::Processing);
2756        }
2757
2758        copperlists.end_of_processing(0).unwrap();
2759        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2760
2761        let reused = copperlists.create().unwrap();
2762        assert_eq!(reused.id, 1);
2763        assert_eq!(reused.get_state(), CopperListState::Initialized);
2764        assert_eq!(reused.msgs.0, 41);
2765    }
2766
2767    #[cfg(all(feature = "std", feature = "async-cl-io", debug_assertions))]
2768    #[test]
2769    #[should_panic(expected = "async end_of_processing expected CopperList #0 to be Processing")]
2770    fn test_async_end_of_processing_wrong_state_panics_in_debug() {
2771        let mut copperlists = CopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2772
2773        let culist = copperlists.create().unwrap();
2774        assert_eq!(culist.id, 0);
2775        assert_eq!(culist.get_state(), CopperListState::Initialized);
2776
2777        let _ = copperlists.end_of_processing(0);
2778    }
2779
2780    #[test]
2781    fn test_runtime_task_input_order() {
2782        let mut config = CuConfig::default();
2783        let graph = config.get_graph_mut(None).unwrap();
2784        let src1_id = graph.add_node(Node::new("a", "Source1")).unwrap();
2785        let src2_id = graph.add_node(Node::new("b", "Source2")).unwrap();
2786        let sink_id = graph.add_node(Node::new("c", "Sink")).unwrap();
2787
2788        assert_eq!(src1_id, 0);
2789        assert_eq!(src2_id, 1);
2790
2791        // note that the source2 connection is before the source1
2792        let src1_type = "src1_type";
2793        let src2_type = "src2_type";
2794        graph.connect(src2_id, sink_id, src2_type).unwrap();
2795        graph.connect(src1_id, sink_id, src1_type).unwrap();
2796
2797        let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
2798        let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
2799        // the edge id depends on the order the connection is created, not
2800        // on the node id, and that is what determines the input order
2801        assert_eq!(src1_edge_id, 1);
2802        assert_eq!(src2_edge_id, 0);
2803
2804        let runtime = compute_runtime_plan(graph).unwrap();
2805        let sink_step = runtime
2806            .steps
2807            .iter()
2808            .find_map(|step| match step {
2809                CuExecutionUnit::Step(step) if step.node_id == sink_id => Some(step),
2810                _ => None,
2811            })
2812            .unwrap();
2813
2814        // since the src2 connection was added before src1 connection, the src2 type should be
2815        // first
2816        assert_eq!(sink_step.input_msg_indices_types[0].msg_type, src2_type);
2817        assert_eq!(sink_step.input_msg_indices_types[1].msg_type, src1_type);
2818    }
2819
2820    #[test]
2821    fn test_runtime_output_ports_unique_ordered() {
2822        let mut config = CuConfig::default();
2823        let graph = config.get_graph_mut(None).unwrap();
2824        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
2825        let dst_a_id = graph.add_node(Node::new("dst_a", "SinkA")).unwrap();
2826        let dst_b_id = graph.add_node(Node::new("dst_b", "SinkB")).unwrap();
2827        let dst_a2_id = graph.add_node(Node::new("dst_a2", "SinkA2")).unwrap();
2828        let dst_c_id = graph.add_node(Node::new("dst_c", "SinkC")).unwrap();
2829
2830        graph.connect(src_id, dst_a_id, "msg::A").unwrap();
2831        graph.connect(src_id, dst_b_id, "msg::B").unwrap();
2832        graph.connect(src_id, dst_a2_id, "msg::A").unwrap();
2833        graph.connect(src_id, dst_c_id, "msg::C").unwrap();
2834
2835        let runtime = compute_runtime_plan(graph).unwrap();
2836        let src_step = runtime
2837            .steps
2838            .iter()
2839            .find_map(|step| match step {
2840                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
2841                _ => None,
2842            })
2843            .unwrap();
2844
2845        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
2846        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B", "msg::C"]);
2847
2848        let dst_a_step = runtime
2849            .steps
2850            .iter()
2851            .find_map(|step| match step {
2852                CuExecutionUnit::Step(step) if step.node_id == dst_a_id => Some(step),
2853                _ => None,
2854            })
2855            .unwrap();
2856        let dst_b_step = runtime
2857            .steps
2858            .iter()
2859            .find_map(|step| match step {
2860                CuExecutionUnit::Step(step) if step.node_id == dst_b_id => Some(step),
2861                _ => None,
2862            })
2863            .unwrap();
2864        let dst_a2_step = runtime
2865            .steps
2866            .iter()
2867            .find_map(|step| match step {
2868                CuExecutionUnit::Step(step) if step.node_id == dst_a2_id => Some(step),
2869                _ => None,
2870            })
2871            .unwrap();
2872        let dst_c_step = runtime
2873            .steps
2874            .iter()
2875            .find_map(|step| match step {
2876                CuExecutionUnit::Step(step) if step.node_id == dst_c_id => Some(step),
2877                _ => None,
2878            })
2879            .unwrap();
2880
2881        assert_eq!(dst_a_step.input_msg_indices_types[0].src_port, 0);
2882        assert_eq!(dst_b_step.input_msg_indices_types[0].src_port, 1);
2883        assert_eq!(dst_a2_step.input_msg_indices_types[0].src_port, 0);
2884        assert_eq!(dst_c_step.input_msg_indices_types[0].src_port, 2);
2885    }
2886
2887    #[test]
2888    fn test_runtime_output_ports_fanout_single() {
2889        let mut config = CuConfig::default();
2890        let graph = config.get_graph_mut(None).unwrap();
2891        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
2892        let dst_a_id = graph.add_node(Node::new("dst_a", "SinkA")).unwrap();
2893        let dst_b_id = graph.add_node(Node::new("dst_b", "SinkB")).unwrap();
2894
2895        graph.connect(src_id, dst_a_id, "i32").unwrap();
2896        graph.connect(src_id, dst_b_id, "i32").unwrap();
2897
2898        let runtime = compute_runtime_plan(graph).unwrap();
2899        let src_step = runtime
2900            .steps
2901            .iter()
2902            .find_map(|step| match step {
2903                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
2904                _ => None,
2905            })
2906            .unwrap();
2907
2908        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
2909        assert_eq!(output_pack.msg_types, vec!["i32"]);
2910    }
2911
2912    #[test]
2913    fn test_runtime_output_ports_include_nc_outputs() {
2914        let mut config = CuConfig::default();
2915        let graph = config.get_graph_mut(None).unwrap();
2916        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
2917        let dst_id = graph.add_node(Node::new("dst", "Sink")).unwrap();
2918        graph.connect(src_id, dst_id, "msg::A").unwrap();
2919        graph
2920            .get_node_mut(src_id)
2921            .expect("missing source node")
2922            .add_nc_output("msg::B", usize::MAX);
2923
2924        let runtime = compute_runtime_plan(graph).unwrap();
2925        let src_step = runtime
2926            .steps
2927            .iter()
2928            .find_map(|step| match step {
2929                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
2930                _ => None,
2931            })
2932            .unwrap();
2933        let dst_step = runtime
2934            .steps
2935            .iter()
2936            .find_map(|step| match step {
2937                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
2938                _ => None,
2939            })
2940            .unwrap();
2941
2942        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
2943        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
2944        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 0);
2945    }
2946
2947    #[test]
2948    fn test_runtime_plan_infers_regular_task_when_outputs_are_nc_only() {
2949        let txt = r#"(
2950            tasks: [
2951                (id: "src", type: "a"),
2952                (id: "regular", type: "b"),
2953            ],
2954            cnx: [
2955                (src: "src", dst: "regular", msg: "msg::A"),
2956                (src: "regular", dst: "__nc__", msg: "msg::B"),
2957            ]
2958        )"#;
2959        let config = CuConfig::deserialize_ron(txt).unwrap();
2960        let graph = config.get_graph(None).unwrap();
2961        let regular_id = graph.get_node_id_by_name("regular").unwrap();
2962
2963        let runtime = compute_runtime_plan(graph).unwrap();
2964        let regular_step = runtime
2965            .steps
2966            .iter()
2967            .find_map(|step| match step {
2968                CuExecutionUnit::Step(step) if step.node_id == regular_id => Some(step),
2969                _ => None,
2970            })
2971            .unwrap();
2972
2973        assert_eq!(regular_step.task_type, CuTaskType::Regular);
2974        assert_eq!(
2975            regular_step.output_msg_pack.as_ref().unwrap().msg_types,
2976            vec!["msg::B"]
2977        );
2978    }
2979
2980    #[test]
2981    fn test_runtime_output_ports_respect_connection_order_with_nc() {
2982        let txt = r#"(
2983            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
2984            cnx: [
2985                (src: "src", dst: "__nc__", msg: "msg::A"),
2986                (src: "src", dst: "sink", msg: "msg::B"),
2987            ]
2988        )"#;
2989        let config = CuConfig::deserialize_ron(txt).unwrap();
2990        let graph = config.get_graph(None).unwrap();
2991        let src_id = graph.get_node_id_by_name("src").unwrap();
2992        let dst_id = graph.get_node_id_by_name("sink").unwrap();
2993
2994        let runtime = compute_runtime_plan(graph).unwrap();
2995        let src_step = runtime
2996            .steps
2997            .iter()
2998            .find_map(|step| match step {
2999                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3000                _ => None,
3001            })
3002            .unwrap();
3003        let dst_step = runtime
3004            .steps
3005            .iter()
3006            .find_map(|step| match step {
3007                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3008                _ => None,
3009            })
3010            .unwrap();
3011
3012        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3013        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
3014        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
3015    }
3016
3017    #[cfg(feature = "std")]
3018    #[test]
3019    fn test_runtime_output_ports_respect_connection_order_with_nc_from_file() {
3020        let txt = r#"(
3021            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
3022            cnx: [
3023                (src: "src", dst: "__nc__", msg: "msg::A"),
3024                (src: "src", dst: "sink", msg: "msg::B"),
3025            ]
3026        )"#;
3027        let tmp = tempfile::NamedTempFile::new().unwrap();
3028        std::fs::write(tmp.path(), txt).unwrap();
3029        let config = crate::config::read_configuration(tmp.path().to_str().unwrap()).unwrap();
3030        let graph = config.get_graph(None).unwrap();
3031        let src_id = graph.get_node_id_by_name("src").unwrap();
3032        let dst_id = graph.get_node_id_by_name("sink").unwrap();
3033
3034        let runtime = compute_runtime_plan(graph).unwrap();
3035        let src_step = runtime
3036            .steps
3037            .iter()
3038            .find_map(|step| match step {
3039                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3040                _ => None,
3041            })
3042            .unwrap();
3043        let dst_step = runtime
3044            .steps
3045            .iter()
3046            .find_map(|step| match step {
3047                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3048                _ => None,
3049            })
3050            .unwrap();
3051
3052        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3053        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
3054        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
3055    }
3056
3057    #[test]
3058    fn test_runtime_output_ports_respect_connection_order_with_nc_primitives() {
3059        let txt = r#"(
3060            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
3061            cnx: [
3062                (src: "src", dst: "__nc__", msg: "i32"),
3063                (src: "src", dst: "sink", msg: "bool"),
3064            ]
3065        )"#;
3066        let config = CuConfig::deserialize_ron(txt).unwrap();
3067        let graph = config.get_graph(None).unwrap();
3068        let src_id = graph.get_node_id_by_name("src").unwrap();
3069        let dst_id = graph.get_node_id_by_name("sink").unwrap();
3070
3071        let runtime = compute_runtime_plan(graph).unwrap();
3072        let src_step = runtime
3073            .steps
3074            .iter()
3075            .find_map(|step| match step {
3076                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3077                _ => None,
3078            })
3079            .unwrap();
3080        let dst_step = runtime
3081            .steps
3082            .iter()
3083            .find_map(|step| match step {
3084                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3085                _ => None,
3086            })
3087            .unwrap();
3088
3089        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3090        assert_eq!(output_pack.msg_types, vec!["i32", "bool"]);
3091        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
3092    }
3093
3094    #[test]
3095    fn test_runtime_plan_diamond_case1() {
3096        // more complex topology that tripped the scheduler
3097        let mut config = CuConfig::default();
3098        let graph = config.get_graph_mut(None).unwrap();
3099        let cam0_id = graph
3100            .add_node(Node::new("cam0", "tasks::IntegerSrcTask"))
3101            .unwrap();
3102        let inf0_id = graph
3103            .add_node(Node::new("inf0", "tasks::Integer2FloatTask"))
3104            .unwrap();
3105        let broadcast_id = graph
3106            .add_node(Node::new("broadcast", "tasks::MergingSinkTask"))
3107            .unwrap();
3108
3109        // case 1 order
3110        graph.connect(cam0_id, broadcast_id, "i32").unwrap();
3111        graph.connect(cam0_id, inf0_id, "i32").unwrap();
3112        graph.connect(inf0_id, broadcast_id, "f32").unwrap();
3113
3114        let edge_cam0_to_broadcast = *graph.get_src_edges(cam0_id).unwrap().first().unwrap();
3115        let edge_cam0_to_inf0 = graph.get_src_edges(cam0_id).unwrap()[1];
3116
3117        assert_eq!(edge_cam0_to_inf0, 0);
3118        assert_eq!(edge_cam0_to_broadcast, 1);
3119
3120        let runtime = compute_runtime_plan(graph).unwrap();
3121        let broadcast_step = runtime
3122            .steps
3123            .iter()
3124            .find_map(|step| match step {
3125                CuExecutionUnit::Step(step) if step.node_id == broadcast_id => Some(step),
3126                _ => None,
3127            })
3128            .unwrap();
3129
3130        assert_eq!(broadcast_step.input_msg_indices_types[0].msg_type, "i32");
3131        assert_eq!(broadcast_step.input_msg_indices_types[1].msg_type, "f32");
3132    }
3133
3134    #[test]
3135    fn test_runtime_plan_diamond_case2() {
3136        // more complex topology that tripped the scheduler variation 2
3137        let mut config = CuConfig::default();
3138        let graph = config.get_graph_mut(None).unwrap();
3139        let cam0_id = graph
3140            .add_node(Node::new("cam0", "tasks::IntegerSrcTask"))
3141            .unwrap();
3142        let inf0_id = graph
3143            .add_node(Node::new("inf0", "tasks::Integer2FloatTask"))
3144            .unwrap();
3145        let broadcast_id = graph
3146            .add_node(Node::new("broadcast", "tasks::MergingSinkTask"))
3147            .unwrap();
3148
3149        // case 2 order
3150        graph.connect(cam0_id, inf0_id, "i32").unwrap();
3151        graph.connect(cam0_id, broadcast_id, "i32").unwrap();
3152        graph.connect(inf0_id, broadcast_id, "f32").unwrap();
3153
3154        let edge_cam0_to_inf0 = *graph.get_src_edges(cam0_id).unwrap().first().unwrap();
3155        let edge_cam0_to_broadcast = graph.get_src_edges(cam0_id).unwrap()[1];
3156
3157        assert_eq!(edge_cam0_to_broadcast, 0);
3158        assert_eq!(edge_cam0_to_inf0, 1);
3159
3160        let runtime = compute_runtime_plan(graph).unwrap();
3161        let broadcast_step = runtime
3162            .steps
3163            .iter()
3164            .find_map(|step| match step {
3165                CuExecutionUnit::Step(step) if step.node_id == broadcast_id => Some(step),
3166                _ => None,
3167            })
3168            .unwrap();
3169
3170        assert_eq!(broadcast_step.input_msg_indices_types[0].msg_type, "i32");
3171        assert_eq!(broadcast_step.input_msg_indices_types[1].msg_type, "f32");
3172    }
3173
3174    // --- anytime plan expansion ---
3175
3176    use crate::config::AnytimeConfig;
3177
3178    fn anytime_node(id: &str, max_refines: Option<u32>) -> Node {
3179        let mut node = Node::new(id, "tasks::AnytimeTask");
3180        node.set_anytime(Some(AnytimeConfig {
3181            max_refines,
3182            ..Default::default()
3183        }));
3184        node
3185    }
3186
3187    /// Renders the plan as `(node_id, phase)` pairs for compact assertions.
3188    fn plan_shape(plan: &CuExecutionLoop) -> Vec<(NodeId, CuStepPhase)> {
3189        plan.steps
3190            .iter()
3191            .map(|unit| match unit {
3192                CuExecutionUnit::Step(step) => (step.node_id, step.phase),
3193                CuExecutionUnit::Loop(_) => panic!("no loops expected"),
3194            })
3195            .collect()
3196    }
3197
3198    /// A manual step, bypassing the planner heuristic so gap placement is
3199    /// deterministic: `inputs`/`output` are copperlist indices.
3200    fn manual_step(node: Node, node_id: NodeId, inputs: &[u32], output: u32) -> CuExecutionUnit {
3201        CuExecutionUnit::Step(Box::new(CuExecutionStep {
3202            node_id,
3203            node,
3204            task_type: CuTaskType::Regular,
3205            phase: CuStepPhase::default(),
3206            input_msg_indices_types: inputs
3207                .iter()
3208                .map(|&culist_index| CuInputMsg {
3209                    culist_index,
3210                    msg_type: "msg::A".to_string(),
3211                    src_port: 0,
3212                    edge_id: 0,
3213                    connection_order: 0,
3214                })
3215                .collect(),
3216            output_msg_pack: Some(CuOutputPack {
3217                culist_index: output,
3218                msg_types: vec!["msg::A".to_string()],
3219            }),
3220        }))
3221    }
3222
3223    #[test]
3224    fn test_anytime_expansion_contiguous_without_gap() {
3225        // src -> any -> sink through the real planner: no independent steps
3226        // between the node and its consumer, so every refine sits before it.
3227        let mut config = CuConfig::default();
3228        let graph = config.get_graph_mut(None).unwrap();
3229        let src_id = graph.add_node(Node::new("src", "tasks::Src")).unwrap();
3230        let any_id = graph.add_node(anytime_node("any", Some(3))).unwrap();
3231        let sink_id = graph.add_node(Node::new("sink", "tasks::Sink")).unwrap();
3232        graph.connect(src_id, any_id, "msg::A").unwrap();
3233        graph.connect(any_id, sink_id, "msg::B").unwrap();
3234
3235        let mut plan = compute_runtime_plan(graph).unwrap();
3236        expand_anytime_steps(&mut plan).unwrap();
3237
3238        assert_eq!(
3239            plan_shape(&plan),
3240            vec![
3241                (src_id, CuStepPhase::Whole),
3242                (any_id, CuStepPhase::AnytimeBase),
3243                (any_id, CuStepPhase::AnytimeRefine),
3244                (any_id, CuStepPhase::AnytimeRefine),
3245                (any_id, CuStepPhase::AnytimeRefine),
3246                (sink_id, CuStepPhase::Whole),
3247            ]
3248        );
3249
3250        // Refine steps read no input and write the base step's output slot.
3251        let (base_pack, refine_steps): (Option<CuOutputPack>, Vec<&CuExecutionStep>) = {
3252            let mut base_pack = None;
3253            let mut refines = Vec::new();
3254            for unit in &plan.steps {
3255                if let CuExecutionUnit::Step(step) = unit {
3256                    match step.phase {
3257                        CuStepPhase::AnytimeBase => base_pack = step.output_msg_pack.clone(),
3258                        CuStepPhase::AnytimeRefine => refines.push(step.as_ref()),
3259                        CuStepPhase::Whole => {}
3260                    }
3261                }
3262            }
3263            (base_pack, refines)
3264        };
3265        let base_pack = base_pack.unwrap();
3266        for refine in refine_steps {
3267            assert!(refine.input_msg_indices_types.is_empty());
3268            let pack = refine.output_msg_pack.as_ref().unwrap();
3269            assert_eq!(pack.culist_index, base_pack.culist_index);
3270        }
3271    }
3272
3273    #[test]
3274    fn test_anytime_expansion_interleaves_with_gap_steps() {
3275        // Manual plan: [any(base at 1), gapA, gapB, consumer], R = 4.
3276        // Expected: base, r, gapA, r, gapB, r, r, consumer.
3277        let mut plan = CuExecutionLoop {
3278            steps: vec![
3279                manual_step(anytime_node("any", Some(4)), 0, &[], 0),
3280                manual_step(Node::new("gap_a", "t"), 1, &[], 1),
3281                manual_step(Node::new("gap_b", "t"), 2, &[], 2),
3282                manual_step(Node::new("consumer", "t"), 3, &[0], 3),
3283            ],
3284            loop_count: None,
3285        };
3286        expand_anytime_steps(&mut plan).unwrap();
3287        assert_eq!(
3288            plan_shape(&plan),
3289            vec![
3290                (0, CuStepPhase::AnytimeBase),
3291                (0, CuStepPhase::AnytimeRefine),
3292                (1, CuStepPhase::Whole),
3293                (0, CuStepPhase::AnytimeRefine),
3294                (2, CuStepPhase::Whole),
3295                (0, CuStepPhase::AnytimeRefine),
3296                (0, CuStepPhase::AnytimeRefine),
3297                (3, CuStepPhase::Whole),
3298            ]
3299        );
3300    }
3301
3302    #[test]
3303    fn test_anytime_expansion_fewer_refines_than_gaps() {
3304        // R = 2 with two gap steps: later gap steps get no quantum after them.
3305        let mut plan = CuExecutionLoop {
3306            steps: vec![
3307                manual_step(anytime_node("any", Some(2)), 0, &[], 0),
3308                manual_step(Node::new("gap_a", "t"), 1, &[], 1),
3309                manual_step(Node::new("gap_b", "t"), 2, &[], 2),
3310                manual_step(Node::new("consumer", "t"), 3, &[0], 3),
3311            ],
3312            loop_count: None,
3313        };
3314        expand_anytime_steps(&mut plan).unwrap();
3315        assert_eq!(
3316            plan_shape(&plan),
3317            vec![
3318                (0, CuStepPhase::AnytimeBase),
3319                (0, CuStepPhase::AnytimeRefine),
3320                (1, CuStepPhase::Whole),
3321                (0, CuStepPhase::AnytimeRefine),
3322                (2, CuStepPhase::Whole),
3323                (3, CuStepPhase::Whole),
3324            ]
3325        );
3326    }
3327
3328    #[test]
3329    fn test_anytime_expansion_without_consumer() {
3330        // No step consumes the node's output: refines sit right after the base.
3331        let mut plan = CuExecutionLoop {
3332            steps: vec![
3333                manual_step(anytime_node("any", Some(2)), 0, &[], 0),
3334                manual_step(Node::new("other", "t"), 1, &[], 1),
3335            ],
3336            loop_count: None,
3337        };
3338        expand_anytime_steps(&mut plan).unwrap();
3339        assert_eq!(
3340            plan_shape(&plan),
3341            vec![
3342                (0, CuStepPhase::AnytimeBase),
3343                (0, CuStepPhase::AnytimeRefine),
3344                (0, CuStepPhase::AnytimeRefine),
3345                (1, CuStepPhase::Whole),
3346            ]
3347        );
3348    }
3349
3350    #[test]
3351    fn test_anytime_expansion_two_nodes_interleave() {
3352        // Two anytime nodes: each expands independently; the second node's base
3353        // and quanta land in the first node's gap and vice versa.
3354        let mut plan = CuExecutionLoop {
3355            steps: vec![
3356                manual_step(anytime_node("any_a", Some(2)), 0, &[], 0),
3357                manual_step(anytime_node("any_b", Some(2)), 1, &[], 1),
3358                manual_step(Node::new("consumer", "t"), 2, &[0, 1], 2),
3359            ],
3360            loop_count: None,
3361        };
3362        expand_anytime_steps(&mut plan).unwrap();
3363        // A expands first: base_a, r_a, [b], r_a, consumer. B then expands in
3364        // place, treating A's second quantum as its gap step.
3365        assert_eq!(
3366            plan_shape(&plan),
3367            vec![
3368                (0, CuStepPhase::AnytimeBase),
3369                (0, CuStepPhase::AnytimeRefine),
3370                (1, CuStepPhase::AnytimeBase),
3371                (1, CuStepPhase::AnytimeRefine),
3372                (0, CuStepPhase::AnytimeRefine),
3373                (1, CuStepPhase::AnytimeRefine),
3374                (2, CuStepPhase::Whole),
3375            ]
3376        );
3377    }
3378
3379    #[test]
3380    fn test_anytime_expansion_requires_max_refines() {
3381        let mut plan = CuExecutionLoop {
3382            steps: vec![
3383                manual_step(anytime_node("any", None), 0, &[], 0),
3384                manual_step(Node::new("consumer", "t"), 1, &[0], 1),
3385            ],
3386            loop_count: None,
3387        };
3388        let err = expand_anytime_steps(&mut plan).unwrap_err();
3389        assert!(err.to_string().contains("needs anytime.max_refines"));
3390    }
3391}