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, CuDirection, 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::resource::ResourceManager;
24#[cfg(feature = "std")]
25use alloc::sync::Arc;
26use compact_str::CompactString;
27use cu29_clock::{ClockProvider, CuDuration, CuTime, RobotClock};
28use cu29_traits::CuResult;
29use cu29_traits::WriteStream;
30use cu29_traits::{CopperListTuple, CuError};
31#[cfg(feature = "std")]
32use rayon::ThreadPool;
33
34#[cfg(target_os = "none")]
35#[allow(unused_imports)]
36use cu29_log::{ANONYMOUS, CuLogEntry, CuLogLevel};
37#[cfg(target_os = "none")]
38#[allow(unused_imports)]
39use cu29_log_derive::info;
40#[cfg(target_os = "none")]
41#[allow(unused_imports)]
42use cu29_log_runtime::log;
43#[cfg(all(target_os = "none", debug_assertions))]
44#[allow(unused_imports)]
45use cu29_log_runtime::log_debug_mode;
46#[cfg(target_os = "none")]
47#[allow(unused_imports)]
48use cu29_value::to_value;
49
50#[cfg(all(feature = "std", any(feature = "async-cl-io", feature = "parallel-rt")))]
51use alloc::alloc::{alloc_zeroed, handle_alloc_error};
52use alloc::boxed::Box;
53use alloc::collections::{BTreeSet, VecDeque};
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                Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1346                Some(Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>),
1347                logging_config.keyframe_interval.unwrap(),
1348            ),
1349            Some(_) => (None, None, 0),
1350            None => (
1351                Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1352                Some(Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>),
1353                DEFAULT_KEYFRAME_INTERVAL,
1354            ),
1355        };
1356
1357        let copperlists_manager = CopperListsManager::new(copperlists_logger)?;
1358        #[cfg(target_os = "none")]
1359        {
1360            let cl_size = core::mem::size_of::<CopperList<P>>();
1361            let total_bytes = cl_size.saturating_mul(NBCL);
1362            info!(
1363                "CuRuntimeBuilder: copperlists count={} cl_size={} total_bytes={}",
1364                NBCL, cl_size, total_bytes
1365            );
1366        }
1367
1368        let keyframes_manager = KeyFramesManager {
1369            inner: KeyFrame::new(),
1370            logger: keyframes_logger,
1371            keyframe_interval,
1372            last_encoded_bytes: 0,
1373            forced_timestamp: None,
1374            locked: false,
1375            capture_size_hint: KEYFRAME_PAYLOAD_HEADER.len(),
1376        };
1377        #[cfg(all(feature = "std", feature = "parallel-rt"))]
1378        let parallel_rt = ParallelRt::new(parts.parallel_rt_metadata)?;
1379
1380        let runtime_config = config.runtime.clone().unwrap_or_default();
1381        runtime_config.validate()?;
1382
1383        Ok(CuRuntime {
1384            subsystem_code: subsystem.code(),
1385            instance_id,
1386            tasks,
1387            bridges,
1388            resources,
1389            thread_pools,
1390            monitor,
1391            execution_probe,
1392            clock,
1393            copperlists_manager,
1394            keyframes_manager,
1395            #[cfg(all(feature = "std", feature = "parallel-rt"))]
1396            parallel_rt,
1397            runtime_config,
1398        })
1399    }
1400}
1401
1402#[cfg(not(feature = "std"))]
1403impl<
1404    'cfg,
1405    CT,
1406    CB,
1407    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
1408    M: CuMonitor,
1409    const NBCL: usize,
1410    TI,
1411    BI,
1412    MI,
1413    CLW,
1414    KFW,
1415> CuRuntimeBuilder<'cfg, CT, CB, P, M, NBCL, TI, BI, MI, CLW, KFW>
1416where
1417    TI: for<'c> Fn(Vec<Option<&'c ComponentConfig>>, &mut ResourceManager) -> CuResult<CT>,
1418    BI: Fn(&CuConfig, &mut ResourceManager) -> CuResult<CB>,
1419    MI: Fn(&CuConfig, CuMonitoringMetadata, CuMonitoringRuntime) -> M,
1420    CLW: WriteStream<CopperList<P>> + 'static,
1421    KFW: WriteStream<KeyFrame> + 'static,
1422{
1423    pub fn build(self) -> CuResult<CuRuntime<CT, CB, P, M, NBCL>> {
1424        let Self {
1425            clock,
1426            config,
1427            mission,
1428            subsystem,
1429            instance_id,
1430            resources,
1431            parts,
1432            copperlists_logger,
1433            keyframes_logger,
1434        } = self;
1435        let mut resources =
1436            resources.ok_or_else(|| CuError::from("Resources missing from CuRuntimeBuilder"))?;
1437
1438        let graph = config.get_graph(Some(mission))?;
1439        let all_instances_configs: Vec<Option<&ComponentConfig>> = graph
1440            .get_all_nodes()
1441            .iter()
1442            .map(|(_, node)| node.get_instance_config())
1443            .collect();
1444
1445        let tasks = (parts.tasks_instanciator)(all_instances_configs, &mut resources)?;
1446
1447        let execution_probe = RuntimeExecutionProbe::default();
1448        let monitor_metadata = CuMonitoringMetadata::new(
1449            CompactString::from(mission),
1450            parts.monitored_components,
1451            parts.culist_component_mapping,
1452            CopperListInfo::new(core::mem::size_of::<CopperList<P>>(), NBCL),
1453            build_monitor_topology(config, mission)?,
1454            None,
1455        )?
1456        .with_subsystem_id(subsystem.id())
1457        .with_instance_id(instance_id);
1458        let monitor_runtime = CuMonitoringRuntime::unavailable();
1459        let monitor = (parts.monitor_instanciator)(config, monitor_metadata, monitor_runtime);
1460        let bridges = (parts.bridges_instanciator)(config, &mut resources)?;
1461
1462        let (copperlists_logger, keyframes_logger, keyframe_interval) = match &config.logging {
1463            Some(logging_config) if logging_config.enable_task_logging => (
1464                Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1465                Some(Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>),
1466                logging_config.keyframe_interval.unwrap(),
1467            ),
1468            Some(_) => (None, None, 0),
1469            None => (
1470                Some(Box::new(copperlists_logger) as Box<dyn WriteStream<CopperList<P>>>),
1471                Some(Box::new(keyframes_logger) as Box<dyn WriteStream<KeyFrame>>),
1472                DEFAULT_KEYFRAME_INTERVAL,
1473            ),
1474        };
1475
1476        let copperlists_manager = CopperListsManager::new(copperlists_logger)?;
1477        #[cfg(target_os = "none")]
1478        {
1479            let cl_size = core::mem::size_of::<CopperList<P>>();
1480            let total_bytes = cl_size.saturating_mul(NBCL);
1481            info!(
1482                "CuRuntimeBuilder: copperlists count={} cl_size={} total_bytes={}",
1483                NBCL, cl_size, total_bytes
1484            );
1485        }
1486
1487        let keyframes_manager = KeyFramesManager {
1488            inner: KeyFrame::new(),
1489            logger: keyframes_logger,
1490            keyframe_interval,
1491            last_encoded_bytes: 0,
1492            forced_timestamp: None,
1493            locked: false,
1494            capture_size_hint: KEYFRAME_PAYLOAD_HEADER.len(),
1495        };
1496
1497        let runtime_config = config.runtime.clone().unwrap_or_default();
1498        runtime_config.validate()?;
1499
1500        Ok(CuRuntime {
1501            subsystem_code: subsystem.code(),
1502            instance_id,
1503            tasks,
1504            bridges,
1505            resources,
1506            monitor,
1507            execution_probe,
1508            clock,
1509            copperlists_manager,
1510            keyframes_manager,
1511            runtime_config,
1512        })
1513    }
1514}
1515
1516/// A keyframe records a distributed snapshot of component state around a copperlist.
1517///
1518/// `serialized_tasks` contains a versioned sequence of length-framed component
1519/// snapshots in their generated execution-wave freeze order.
1520#[derive(Clone, Encode, Decode)]
1521pub struct KeyFrame {
1522    // This is the id of the copper list that this keyframe is associated with (recorded before the copperlist).
1523    pub culistid: u64,
1524    // This is the timestamp when the keyframe was created, using the robot clock.
1525    pub timestamp: CuTime,
1526    // Versioned, length-framed bincode snapshots of all generated components.
1527    pub serialized_tasks: Vec<u8>,
1528}
1529
1530impl KeyFrame {
1531    fn new() -> Self {
1532        KeyFrame {
1533            culistid: 0,
1534            timestamp: CuTime::default(),
1535            serialized_tasks: KEYFRAME_PAYLOAD_HEADER.to_vec(),
1536        }
1537    }
1538
1539    /// This is to be able to avoid reallocations
1540    fn reset(&mut self, culistid: u64, timestamp: CuTime) {
1541        self.culistid = culistid;
1542        self.timestamp = timestamp;
1543        self.serialized_tasks.clear();
1544        self.serialized_tasks
1545            .extend_from_slice(KEYFRAME_PAYLOAD_HEADER);
1546    }
1547
1548    /// Append one length-framed component snapshot in a single `freeze` pass.
1549    fn add_frozen_task(&mut self, task: &impl Freezable) -> Result<usize, EncodeError> {
1550        let cfg = bincode::config::standard();
1551        let start = self.serialized_tasks.len();
1552        let payload_offset =
1553            start
1554                .checked_add(KEYFRAME_FRAME_HEADER_LEN)
1555                .ok_or(EncodeError::Other(
1556                    "keyframe component frame offset overflow",
1557                ))?;
1558        if payload_offset > self.serialized_tasks.capacity() {
1559            return Err(EncodeError::UnexpectedEnd);
1560        }
1561
1562        self.serialized_tasks.resize(payload_offset, 0);
1563        let length_offset = start;
1564        self.serialized_tasks[length_offset..payload_offset].fill(0);
1565
1566        let mut encoder =
1567            EncoderImpl::<_, _>::new(PreallocatedVecWriter(&mut self.serialized_tasks), cfg);
1568        if let Err(error) = BincodeAdapter(task).encode(&mut encoder) {
1569            self.serialized_tasks.truncate(start);
1570            return Err(error);
1571        }
1572        let payload_len = encoder.into_writer().0.len() - payload_offset;
1573        let payload_len = u32::try_from(payload_len).map_err(|_| {
1574            self.serialized_tasks.truncate(start);
1575            EncodeError::OtherString(
1576                "keyframe component snapshot exceeds the u32 frame limit".to_string(),
1577            )
1578        })?;
1579        self.serialized_tasks
1580            .truncate(payload_offset + payload_len as usize);
1581        self.serialized_tasks[length_offset..payload_offset]
1582            .copy_from_slice(&payload_len.to_le_bytes());
1583        Ok(self.serialized_tasks.len() - start)
1584    }
1585}
1586
1587const KEYFRAME_PAYLOAD_MAGIC: &[u8; 4] = b"CUKF";
1588const KEYFRAME_PAYLOAD_VERSION: u8 = 1;
1589const KEYFRAME_PAYLOAD_HEADER: &[u8; 5] = b"CUKF\x01";
1590const KEYFRAME_FRAME_HEADER_LEN: usize = 4;
1591
1592/// Reader for the versioned component frames inside a [`KeyFrame`].
1593#[doc(hidden)]
1594pub struct KeyFramePayloadReader<'a> {
1595    remaining: &'a [u8],
1596}
1597
1598impl<'a> KeyFramePayloadReader<'a> {
1599    /// Validate a keyframe payload and prepare to consume its component frames.
1600    pub fn new(keyframe: &'a KeyFrame) -> CuResult<Self> {
1601        let payload = keyframe.serialized_tasks.as_slice();
1602        if payload.len() < KEYFRAME_PAYLOAD_HEADER.len()
1603            || payload[..KEYFRAME_PAYLOAD_MAGIC.len()] != *KEYFRAME_PAYLOAD_MAGIC
1604        {
1605            return Err(CuError::from(
1606                "Unsupported legacy keyframe payload: expected framed format version 1",
1607            ));
1608        }
1609        let version = payload[KEYFRAME_PAYLOAD_MAGIC.len()];
1610        if version != KEYFRAME_PAYLOAD_VERSION {
1611            return Err(CuError::from(format!(
1612                "Unsupported keyframe payload version {version}; expected {KEYFRAME_PAYLOAD_VERSION}"
1613            )));
1614        }
1615        Ok(Self {
1616            remaining: &payload[KEYFRAME_PAYLOAD_HEADER.len()..],
1617        })
1618    }
1619
1620    /// Consume the next component frame in generated execution order.
1621    pub fn next_frame(&mut self) -> CuResult<&'a [u8]> {
1622        if self.remaining.len() < KEYFRAME_FRAME_HEADER_LEN {
1623            return Err(CuError::from("Keyframe ended before next component frame"));
1624        }
1625        let payload_len = u32::from_le_bytes(
1626            self.remaining[..KEYFRAME_FRAME_HEADER_LEN]
1627                .try_into()
1628                .map_err(|_| CuError::from("Invalid keyframe component frame length"))?,
1629        ) as usize;
1630        let frame_end = KEYFRAME_FRAME_HEADER_LEN
1631            .checked_add(payload_len)
1632            .ok_or_else(|| CuError::from("Keyframe component frame length overflow"))?;
1633        if frame_end > self.remaining.len() {
1634            return Err(CuError::from("Keyframe component frame is truncated"));
1635        }
1636        let payload = &self.remaining[KEYFRAME_FRAME_HEADER_LEN..frame_end];
1637        self.remaining = &self.remaining[frame_end..];
1638        Ok(payload)
1639    }
1640
1641    /// Reject trailing component frames that the generated restore did not consume.
1642    pub fn finish(self) -> CuResult<()> {
1643        if self.remaining.is_empty() {
1644            Ok(())
1645        } else {
1646            Err(CuError::from("Keyframe contains trailing component data"))
1647        }
1648    }
1649}
1650
1651struct FrameSliceReader<'a> {
1652    remaining: &'a [u8],
1653}
1654
1655impl Reader for FrameSliceReader<'_> {
1656    fn read(&mut self, bytes: &mut [u8]) -> Result<(), DecodeError> {
1657        if bytes.len() > self.remaining.len() {
1658            return Err(DecodeError::UnexpectedEnd {
1659                additional: bytes.len() - self.remaining.len(),
1660            });
1661        }
1662        let (read, remaining) = self.remaining.split_at(bytes.len());
1663        bytes.copy_from_slice(read);
1664        self.remaining = remaining;
1665        Ok(())
1666    }
1667
1668    fn peek_read(&mut self, length: usize) -> Option<&[u8]> {
1669        self.remaining.get(..length)
1670    }
1671
1672    fn consume(&mut self, length: usize) {
1673        self.remaining = self.remaining.get(length..).unwrap_or_default();
1674    }
1675}
1676
1677/// Thaw one component from an isolated keyframe frame and require full consumption.
1678#[doc(hidden)]
1679pub fn thaw_keyframe_component(item: &mut impl Freezable, frame: &[u8]) -> CuResult<()> {
1680    let reader = FrameSliceReader { remaining: frame };
1681    let mut decoder = DecoderImpl::new(reader, bincode::config::standard(), ());
1682    item.thaw(&mut decoder)
1683        .map_err(|error| CuError::from(format!("Failed to thaw keyframe component: {error}")))?;
1684    let trailing = decoder.reader().remaining.len();
1685    if trailing != 0 {
1686        return Err(CuError::from(format!(
1687            "Keyframe component snapshot has {} trailing bytes",
1688            trailing
1689        )));
1690    }
1691    Ok(())
1692}
1693
1694/// Identifies where the effective runtime configuration came from.
1695#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1696pub enum RuntimeLifecycleConfigSource {
1697    ProgrammaticOverride,
1698    ExternalFile,
1699    BundledDefault,
1700}
1701
1702/// Stack and process identification metadata persisted in the runtime lifecycle log.
1703#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1704pub struct RuntimeLifecycleStackInfo {
1705    pub app_name: String,
1706    pub app_version: String,
1707    pub git_commit: Option<String>,
1708    pub git_dirty: Option<bool>,
1709    pub subsystem_id: Option<String>,
1710    pub subsystem_code: u16,
1711    pub instance_id: u32,
1712}
1713
1714/// Runtime lifecycle events emitted in the dedicated lifecycle section.
1715#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1716pub enum RuntimeLifecycleEvent {
1717    Instantiated {
1718        config_source: RuntimeLifecycleConfigSource,
1719        effective_config_ron: String,
1720        stack: RuntimeLifecycleStackInfo,
1721    },
1722    MissionStarted {
1723        mission: String,
1724    },
1725    MissionStopped {
1726        mission: String,
1727        // TODO(lifecycle): replace free-form reason with a typed stop reason enum once
1728        // std/no-std behavior and panic integration are split in a follow-up PR.
1729        reason: String,
1730    },
1731    // TODO(lifecycle): wire panic hook / no_std equivalent to emit this event consistently.
1732    Panic {
1733        message: String,
1734        file: Option<String>,
1735        line: Option<u32>,
1736        column: Option<u32>,
1737    },
1738    ShutdownCompleted,
1739}
1740
1741/// One event record persisted in the `UnifiedLogType::RuntimeLifecycle` section.
1742#[derive(Clone, Encode, Decode, Debug, PartialEq, Eq)]
1743pub struct RuntimeLifecycleRecord {
1744    pub timestamp: CuTime,
1745    pub event: RuntimeLifecycleEvent,
1746}
1747
1748impl<
1749    CT,
1750    CB,
1751    P: CopperListTuple + CuListZeroedInit + Default + AsyncCopperListPayload + 'static,
1752    M: CuMonitor,
1753    const NBCL: usize,
1754> CuRuntime<CT, CB, P, M, NBCL>
1755{
1756    /// Records runtime execution progress in the shared probe.
1757    ///
1758    /// This is intentionally lightweight and does not call monitor callbacks.
1759    #[inline]
1760    pub fn record_execution_marker(&self, marker: ExecutionMarker) {
1761        self.execution_probe.record(marker);
1762    }
1763
1764    /// Returns a shared reference to the concrete runtime execution probe.
1765    ///
1766    /// The generated runtime uses this when it needs a uniform
1767    /// `&RuntimeExecutionProbe` view across `std` and `no_std` builds.
1768    #[inline]
1769    pub fn execution_probe_ref(&self) -> &RuntimeExecutionProbe {
1770        #[cfg(feature = "std")]
1771        {
1772            self.execution_probe.as_ref()
1773        }
1774
1775        #[cfg(not(feature = "std"))]
1776        {
1777            &self.execution_probe
1778        }
1779    }
1780}
1781
1782/// Copper tasks can be of 3 types:
1783/// - Source: only producing output messages (usually used for drivers)
1784/// - Regular: processing input messages and producing output messages, more like compute nodes.
1785/// - Sink: only consuming input messages (usually used for actuators)
1786#[derive(Debug, PartialEq, Eq, Clone, Copy)]
1787pub enum CuTaskType {
1788    Source,
1789    Regular,
1790    Sink,
1791}
1792
1793impl From<TaskKind> for CuTaskType {
1794    fn from(value: TaskKind) -> Self {
1795        match value {
1796            TaskKind::Source => CuTaskType::Source,
1797            TaskKind::Regular => CuTaskType::Regular,
1798            TaskKind::Sink => CuTaskType::Sink,
1799        }
1800    }
1801}
1802
1803#[derive(Debug, Clone)]
1804pub struct CuOutputPack {
1805    pub culist_index: u32,
1806    pub msg_types: Vec<String>,
1807}
1808
1809#[derive(Debug, Clone)]
1810pub struct CuInputMsg {
1811    pub culist_index: u32,
1812    pub msg_type: String,
1813    pub src_port: usize,
1814    pub edge_id: usize,
1815    pub connection_order: usize,
1816}
1817
1818/// Which part of its node's job one plan step runs.
1819///
1820/// This is deliberately not folded into [`CuTaskType`]: that enum encodes the
1821/// graph role (Source/Regular/Sink) and drives call-shape decisions everywhere,
1822/// while the phase is orthogonal — an anytime node stays `Regular` and appears
1823/// as one base step plus `max_refines` refine steps (see
1824/// [`expand_anytime_steps`]).
1825#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1826pub enum CuStepPhase {
1827    /// The whole `process()` of a non-anytime task.
1828    #[default]
1829    Whole,
1830    /// The dead-on-arrival age check plus `base()`, at the node's topological
1831    /// position.
1832    AnytimeBase,
1833    /// Exactly one `refine()` quantum.
1834    AnytimeRefine,
1835}
1836
1837/// This structure represents a step in the execution plan.
1838pub struct CuExecutionStep {
1839    /// NodeId: node id of the task to execute
1840    pub node_id: NodeId,
1841    /// Node: node instance
1842    pub node: Node,
1843    /// CuTaskType: type of the task
1844    pub task_type: CuTaskType,
1845    /// Which part of the node's job this step runs (anytime nodes span several
1846    /// steps; everything else is a single `Whole` step).
1847    pub phase: CuStepPhase,
1848
1849    /// the indices in the copper list of the input messages and their types
1850    /// (empty for anytime refine steps: refinement reads no input)
1851    pub input_msg_indices_types: Vec<CuInputMsg>,
1852
1853    /// the index in the copper list of the output message and its type
1854    /// (an anytime node's refine steps carry the same pack as its base step)
1855    pub output_msg_pack: Option<CuOutputPack>,
1856}
1857
1858impl Debug for CuExecutionStep {
1859    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
1860        f.write_str(format!("   CuExecutionStep: Node Id: {}\n", self.node_id).as_str())?;
1861        f.write_str(format!("                  task_type: {:?}\n", self.node.get_type()).as_str())?;
1862        f.write_str(format!("                       task: {:?}\n", self.task_type).as_str())?;
1863        f.write_str(format!("                      phase: {:?}\n", self.phase).as_str())?;
1864        f.write_str(
1865            format!(
1866                "              input_msg_types: {:?}\n",
1867                self.input_msg_indices_types
1868            )
1869            .as_str(),
1870        )?;
1871        f.write_str(format!("       output_msg_pack: {:?}\n", self.output_msg_pack).as_str())?;
1872        Ok(())
1873    }
1874}
1875
1876/// This structure represents a loop in the execution plan.
1877/// It is used to represent a sequence of Execution units (loop or steps) that are executed
1878/// multiple times.
1879/// if loop_count is None, the loop is infinite.
1880pub struct CuExecutionLoop {
1881    pub steps: Vec<CuExecutionUnit>,
1882    pub loop_count: Option<u32>,
1883}
1884
1885impl Debug for CuExecutionLoop {
1886    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
1887        f.write_str("CuExecutionLoop:\n")?;
1888        for step in &self.steps {
1889            match step {
1890                CuExecutionUnit::Step(step) => {
1891                    step.fmt(f)?;
1892                }
1893                CuExecutionUnit::Loop(l) => {
1894                    l.fmt(f)?;
1895                }
1896            }
1897        }
1898
1899        f.write_str(format!("   count: {:?}", self.loop_count).as_str())?;
1900        Ok(())
1901    }
1902}
1903
1904/// This structure represents a step in the execution plan.
1905#[derive(Debug)]
1906pub enum CuExecutionUnit {
1907    Step(Box<CuExecutionStep>),
1908    Loop(CuExecutionLoop),
1909}
1910
1911fn find_output_pack_from_nodeid(
1912    node_id: NodeId,
1913    steps: &Vec<CuExecutionUnit>,
1914) -> Option<CuOutputPack> {
1915    for step in steps {
1916        match step {
1917            CuExecutionUnit::Loop(loop_unit) => {
1918                if let Some(output_pack) = find_output_pack_from_nodeid(node_id, &loop_unit.steps) {
1919                    return Some(output_pack);
1920                }
1921            }
1922            CuExecutionUnit::Step(step) if step.node_id == node_id => {
1923                return step.output_msg_pack.clone();
1924            }
1925            _ => {}
1926        }
1927    }
1928    None
1929}
1930
1931pub fn find_task_type_for_id(graph: &CuGraph, node_id: NodeId) -> CuResult<CuTaskType> {
1932    let node = graph
1933        .get_node(node_id)
1934        .ok_or_else(|| CuError::from(format!("Node id {node_id} not found")))?;
1935
1936    if node.get_flavor() == crate::config::Flavor::Task {
1937        return resolve_task_kind_for_id(graph, node_id).map(Into::into);
1938    }
1939
1940    let has_inputs = !graph.get_dst_edges(node_id)?.is_empty();
1941    let has_outputs = !graph.get_src_edges(node_id)?.is_empty();
1942    Ok(match (has_inputs, has_outputs) {
1943        (false, true) => CuTaskType::Source,
1944        (true, false) => CuTaskType::Sink,
1945        _ => CuTaskType::Regular,
1946    })
1947}
1948
1949/// Preserve the original serialized connection order across missions.
1950///
1951/// Edge ids are assigned per mission graph, so they are not stable enough to describe a shared
1952/// input layout when missions selectively include connections.
1953fn sort_inputs_by_connection_order(input_msg_indices_types: &mut [CuInputMsg]) {
1954    input_msg_indices_types.sort_by_key(|input| input.connection_order);
1955}
1956
1957/// Explores a subbranch and build the partial plan out of it.
1958fn plan_tasks_tree_branch(
1959    graph: &CuGraph,
1960    mut next_culist_output_index: u32,
1961    starting_point: NodeId,
1962    plan: &mut Vec<CuExecutionUnit>,
1963) -> CuResult<(u32, bool)> {
1964    #[cfg(all(feature = "std", feature = "macro_debug"))]
1965    eprintln!("-- starting branch from node {starting_point}");
1966
1967    let mut handled = false;
1968
1969    for id in graph.bfs_nodes(starting_point) {
1970        let node_ref = graph.get_node(id).unwrap();
1971        #[cfg(all(feature = "std", feature = "macro_debug"))]
1972        eprintln!("  Visiting node: {node_ref:?}");
1973
1974        let mut input_msg_indices_types: Vec<CuInputMsg> = Vec::new();
1975        let output_msg_pack: Option<CuOutputPack>;
1976        let task_type = find_task_type_for_id(graph, id)?;
1977
1978        match task_type {
1979            CuTaskType::Source => {
1980                #[cfg(all(feature = "std", feature = "macro_debug"))]
1981                eprintln!("    → Source node, assign output index {next_culist_output_index}");
1982                let msg_types = graph.get_node_output_msg_types_by_id(id)?;
1983                if msg_types.is_empty() {
1984                    return Err(CuError::from(format!(
1985                        "Source node '{}' has no declared outputs",
1986                        node_ref.get_id()
1987                    )));
1988                }
1989                output_msg_pack = Some(CuOutputPack {
1990                    culist_index: next_culist_output_index,
1991                    msg_types,
1992                });
1993                next_culist_output_index += 1;
1994            }
1995            CuTaskType::Sink => {
1996                let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
1997                edge_ids.sort();
1998                #[cfg(all(feature = "std", feature = "macro_debug"))]
1999                eprintln!("    → Sink with incoming edges: {edge_ids:?}");
2000                for edge_id in edge_ids {
2001                    let edge = graph
2002                        .edge(edge_id)
2003                        .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
2004                    let pid = graph
2005                        .get_node_id_by_name(edge.src.as_str())
2006                        .unwrap_or_else(|| {
2007                            panic!("Missing source node '{}' for edge {edge_id}", edge.src)
2008                        });
2009                    let output_pack = find_output_pack_from_nodeid(pid, plan);
2010                    if let Some(output_pack) = output_pack {
2011                        #[cfg(all(feature = "std", feature = "macro_debug"))]
2012                        eprintln!("      ✓ Input from {pid} ready: {output_pack:?}");
2013                        let msg_type = edge.msg.as_str();
2014                        let src_port = output_pack
2015                            .msg_types
2016                            .iter()
2017                            .position(|msg| msg == msg_type)
2018                            .unwrap_or_else(|| {
2019                                panic!(
2020                                    "Missing output port for message type '{msg_type}' on node {pid}"
2021                                )
2022                            });
2023                        input_msg_indices_types.push(CuInputMsg {
2024                            culist_index: output_pack.culist_index,
2025                            msg_type: msg_type.to_string(),
2026                            src_port,
2027                            edge_id,
2028                            connection_order: edge.order,
2029                        });
2030                    } else {
2031                        #[cfg(all(feature = "std", feature = "macro_debug"))]
2032                        eprintln!("      ✗ Input from {pid} not ready, returning");
2033                        return Ok((next_culist_output_index, handled));
2034                    }
2035                }
2036                output_msg_pack = Some(CuOutputPack {
2037                    culist_index: next_culist_output_index,
2038                    msg_types: Vec::from(["()".to_string()]),
2039                });
2040                next_culist_output_index += 1;
2041            }
2042            CuTaskType::Regular => {
2043                let mut edge_ids = graph.get_dst_edges(id).unwrap_or_default();
2044                edge_ids.sort();
2045                #[cfg(all(feature = "std", feature = "macro_debug"))]
2046                eprintln!("    → Regular task with incoming edges: {edge_ids:?}");
2047                for edge_id in edge_ids {
2048                    let edge = graph
2049                        .edge(edge_id)
2050                        .unwrap_or_else(|| panic!("Missing edge {edge_id} for node {id}"));
2051                    let pid = graph
2052                        .get_node_id_by_name(edge.src.as_str())
2053                        .unwrap_or_else(|| {
2054                            panic!("Missing source node '{}' for edge {edge_id}", edge.src)
2055                        });
2056                    let output_pack = find_output_pack_from_nodeid(pid, plan);
2057                    if let Some(output_pack) = output_pack {
2058                        #[cfg(all(feature = "std", feature = "macro_debug"))]
2059                        eprintln!("      ✓ Input from {pid} ready: {output_pack:?}");
2060                        let msg_type = edge.msg.as_str();
2061                        let src_port = output_pack
2062                            .msg_types
2063                            .iter()
2064                            .position(|msg| msg == msg_type)
2065                            .unwrap_or_else(|| {
2066                                panic!(
2067                                    "Missing output port for message type '{msg_type}' on node {pid}"
2068                                )
2069                            });
2070                        input_msg_indices_types.push(CuInputMsg {
2071                            culist_index: output_pack.culist_index,
2072                            msg_type: msg_type.to_string(),
2073                            src_port,
2074                            edge_id,
2075                            connection_order: edge.order,
2076                        });
2077                    } else {
2078                        #[cfg(all(feature = "std", feature = "macro_debug"))]
2079                        eprintln!("      ✗ Input from {pid} not ready, returning");
2080                        return Ok((next_culist_output_index, handled));
2081                    }
2082                }
2083                let msg_types = graph.get_node_output_msg_types_by_id(id)?;
2084                if msg_types.is_empty() {
2085                    return Err(CuError::from(format!(
2086                        "Regular node '{}' has no declared outputs",
2087                        node_ref.get_id()
2088                    )));
2089                }
2090                output_msg_pack = Some(CuOutputPack {
2091                    culist_index: next_culist_output_index,
2092                    msg_types,
2093                });
2094                next_culist_output_index += 1;
2095            }
2096        }
2097
2098        sort_inputs_by_connection_order(&mut input_msg_indices_types);
2099
2100        if let Some(pos) = plan
2101            .iter()
2102            .position(|step| matches!(step, CuExecutionUnit::Step(s) if s.node_id == id))
2103        {
2104            #[cfg(all(feature = "std", feature = "macro_debug"))]
2105            eprintln!("    → Already in plan, modifying existing step");
2106            let mut step = plan.remove(pos);
2107            if let CuExecutionUnit::Step(ref mut s) = step {
2108                s.input_msg_indices_types = input_msg_indices_types;
2109            }
2110            plan.push(step);
2111        } else {
2112            #[cfg(all(feature = "std", feature = "macro_debug"))]
2113            eprintln!("    → New step added to plan");
2114            let step = CuExecutionStep {
2115                node_id: id,
2116                node: node_ref.clone(),
2117                task_type,
2118                phase: CuStepPhase::default(),
2119                input_msg_indices_types,
2120                output_msg_pack,
2121            };
2122            plan.push(CuExecutionUnit::Step(Box::new(step)));
2123        }
2124
2125        handled = true;
2126    }
2127
2128    #[cfg(all(feature = "std", feature = "macro_debug"))]
2129    eprintln!("-- finished branch from node {starting_point} with handled={handled}");
2130    Ok((next_culist_output_index, handled))
2131}
2132
2133/// This is the main heuristics to compute an execution plan at compilation time.
2134/// TODO(gbin): Make that heuristic pluggable.
2135pub fn compute_runtime_plan(graph: &CuGraph) -> CuResult<CuExecutionLoop> {
2136    #[cfg(all(feature = "std", feature = "macro_debug"))]
2137    eprintln!("[runtime plan]");
2138    let mut plan = Vec::new();
2139    let mut next_culist_output_index = 0u32;
2140
2141    let mut queue: VecDeque<NodeId> = VecDeque::new();
2142    for node_id in graph.node_ids() {
2143        if find_task_type_for_id(graph, node_id)? == CuTaskType::Source {
2144            queue.push_back(node_id);
2145        }
2146    }
2147
2148    #[cfg(all(feature = "std", feature = "macro_debug"))]
2149    eprintln!("Initial source nodes: {queue:?}");
2150
2151    while let Some(start_node) = queue.pop_front() {
2152        #[cfg(all(feature = "std", feature = "macro_debug"))]
2153        eprintln!("→ Starting BFS from source {start_node}");
2154        for node_id in graph.bfs_nodes(start_node) {
2155            let already_in_plan = plan
2156                .iter()
2157                .any(|unit| matches!(unit, CuExecutionUnit::Step(s) if s.node_id == node_id));
2158            if already_in_plan {
2159                #[cfg(all(feature = "std", feature = "macro_debug"))]
2160                eprintln!("    → Node {node_id} already planned, skipping");
2161                continue;
2162            }
2163
2164            #[cfg(all(feature = "std", feature = "macro_debug"))]
2165            eprintln!("    Planning from node {node_id}");
2166            let (new_index, handled) =
2167                plan_tasks_tree_branch(graph, next_culist_output_index, node_id, &mut plan)?;
2168            next_culist_output_index = new_index;
2169
2170            if !handled {
2171                #[cfg(all(feature = "std", feature = "macro_debug"))]
2172                eprintln!("    ✗ Node {node_id} was not handled, skipping enqueue of neighbors");
2173                continue;
2174            }
2175
2176            #[cfg(all(feature = "std", feature = "macro_debug"))]
2177            eprintln!("    ✓ Node {node_id} handled successfully, enqueueing neighbors");
2178            for neighbor in graph.get_neighbor_ids(node_id, CuDirection::Outgoing) {
2179                #[cfg(all(feature = "std", feature = "macro_debug"))]
2180                eprintln!("      → Enqueueing neighbor {neighbor}");
2181                queue.push_back(neighbor);
2182            }
2183        }
2184    }
2185
2186    let mut planned_nodes = BTreeSet::new();
2187    for unit in &plan {
2188        if let CuExecutionUnit::Step(step) = unit {
2189            planned_nodes.insert(step.node_id);
2190        }
2191    }
2192
2193    let mut missing = Vec::new();
2194    for node_id in graph.node_ids() {
2195        if !planned_nodes.contains(&node_id) {
2196            if let Some(node) = graph.get_node(node_id) {
2197                missing.push(node.get_id().to_string());
2198            } else {
2199                missing.push(format!("node_id_{node_id}"));
2200            }
2201        }
2202    }
2203
2204    if !missing.is_empty() {
2205        missing.sort();
2206        return Err(CuError::from(format!(
2207            "Execution plan could not include all nodes. Missing: {}. Check for loopback or missing source connections.",
2208            missing.join(", ")
2209        )));
2210    }
2211
2212    Ok(CuExecutionLoop {
2213        steps: plan,
2214        loop_count: None,
2215    })
2216}
2217
2218/// Expands every foreground anytime node of an already-computed plan into its
2219/// chunked steps.
2220///
2221/// The node's single `Whole` step becomes an [`CuStepPhase::AnytimeBase`] step
2222/// at its topological position, and `max_refines` [`CuStepPhase::AnytimeRefine`]
2223/// steps (one `refine()` quantum each) are woven between it and the earliest
2224/// step consuming the node's output: one immediately after the base step, one
2225/// after each subsequent independent step, and the remainder contiguously
2226/// before the consumer. If `max_refines` is smaller than the gap, later gap
2227/// steps get no quantum between them; if the node has no consumer in this
2228/// plan, every refine step sits right after the base step.
2229///
2230/// The refine count must be known here — the emission count *is* the iteration
2231/// bound — which is why `max_refines` is mandatory for foreground anytime
2232/// nodes. How many quanta run and where they sit between other steps is
2233/// entirely this compile-time scheduling decision; the generated code carries
2234/// no counter.
2235pub fn expand_anytime_steps(plan: &mut CuExecutionLoop) -> CuResult<()> {
2236    loop {
2237        // One node at a time: expanded steps get a non-`Whole` phase, so the
2238        // scan converges even though insertions shift positions.
2239        let Some(base_pos) = plan.steps.iter().position(|unit| {
2240            matches!(
2241                unit,
2242                CuExecutionUnit::Step(step) if step.phase == CuStepPhase::Whole
2243                    && step.node.anytime().is_some()
2244                    && !step.node.is_background()
2245            )
2246        }) else {
2247            return Ok(());
2248        };
2249
2250        let CuExecutionUnit::Step(base_step) = &mut plan.steps[base_pos] else {
2251            unreachable!("position() only matches steps");
2252        };
2253        let anytime = base_step
2254            .node
2255            .anytime()
2256            .expect("position() only matches anytime nodes");
2257        // Defense in depth for direct API callers: the macro pipeline rejects
2258        // this at configuration time (config.rs validate_anytime_graph).
2259        let Some(max_refines) = anytime.max_refines else {
2260            return Err(CuError::from(format!(
2261                "Task '{}': a foreground anytime task needs anytime.max_refines to expand into a static plan.",
2262                base_step.node.get_id()
2263            )));
2264        };
2265        base_step.phase = CuStepPhase::AnytimeBase;
2266        let output_pack = base_step.output_msg_pack.clone().ok_or_else(|| {
2267            CuError::from(format!(
2268                "Task '{}': an anytime task needs an output to refine.",
2269                base_step.node.get_id()
2270            ))
2271        })?;
2272        let output_index = output_pack.culist_index;
2273        let node_id = base_step.node_id;
2274        let node = base_step.node.clone();
2275        let task_type = base_step.task_type;
2276
2277        let refine_step = || {
2278            CuExecutionUnit::Step(Box::new(CuExecutionStep {
2279                node_id,
2280                node: node.clone(),
2281                task_type,
2282                phase: CuStepPhase::AnytimeRefine,
2283                input_msg_indices_types: Vec::new(),
2284                output_msg_pack: Some(output_pack.clone()),
2285            }))
2286        };
2287
2288        // Earliest step consuming the node's output; refine steps never match
2289        // (their inputs are empty), so already-expanded nodes stay inert here.
2290        let consumer_pos = plan.steps[base_pos + 1..]
2291            .iter()
2292            .position(|unit| {
2293                matches!(
2294                    unit,
2295                    CuExecutionUnit::Step(step) if step
2296                        .input_msg_indices_types
2297                        .iter()
2298                        .any(|input| input.culist_index == output_index)
2299                )
2300            })
2301            .map(|offset| base_pos + 1 + offset)
2302            .unwrap_or(base_pos + 1);
2303
2304        let mut tail = plan.steps.split_off(base_pos + 1);
2305        let suffix = tail.split_off(consumer_pos - base_pos - 1);
2306        let gap = tail;
2307
2308        // max_refines >= 1 is enforced by AnytimeConfig::validate.
2309        let mut remaining = max_refines.max(1);
2310        remaining -= 1;
2311        plan.steps.push(refine_step());
2312        for gap_unit in gap {
2313            plan.steps.push(gap_unit);
2314            if remaining > 0 {
2315                remaining -= 1;
2316                plan.steps.push(refine_step());
2317            }
2318        }
2319        for _ in 0..remaining {
2320            plan.steps.push(refine_step());
2321        }
2322        plan.steps.extend(suffix);
2323    }
2324}
2325
2326//tests
2327#[cfg(test)]
2328mod tests {
2329    use super::*;
2330    use crate::config::Node;
2331    use crate::context::CuContext;
2332    use crate::cutask::CuSinkTask;
2333    use crate::cutask::{CuSrcTask, Freezable};
2334    use crate::monitoring::NoMonitor;
2335    use crate::reflect::Reflect;
2336    use bincode::Encode;
2337    use core::cell::Cell;
2338    use cu29_traits::{ErasedCuStampedData, ErasedCuStampedDataSet, MatchingTasks};
2339    use serde_derive::{Deserialize, Serialize};
2340    #[cfg(feature = "std")]
2341    use std::sync::{Arc, Mutex};
2342
2343    struct CountingSnapshot<'a> {
2344        calls: &'a Cell<usize>,
2345        value: u32,
2346        fail: bool,
2347    }
2348
2349    impl Freezable for CountingSnapshot<'_> {
2350        fn freeze<E: bincode::enc::Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
2351            self.calls.set(self.calls.get() + 1);
2352            self.value.encode(encoder)?;
2353            if self.fail {
2354                Err(EncodeError::OtherString(
2355                    "intentional freeze failure".to_string(),
2356                ))
2357            } else {
2358                Ok(())
2359            }
2360        }
2361    }
2362
2363    #[derive(Default)]
2364    struct SnapshotValue(u32);
2365
2366    impl Freezable for SnapshotValue {
2367        fn thaw<D: bincode::de::Decoder>(&mut self, decoder: &mut D) -> Result<(), DecodeError> {
2368            self.0 = u32::decode(decoder)?;
2369            Ok(())
2370        }
2371    }
2372
2373    #[test]
2374    fn keyframe_frames_freeze_once_and_roll_back_only_failed_frame() {
2375        let calls = Cell::new(0);
2376        let mut keyframe = KeyFrame::new();
2377        keyframe
2378            .serialized_tasks
2379            .try_reserve_exact(MIN_KEYFRAME_CAPTURE_CAPACITY)
2380            .unwrap();
2381        keyframe.reset(7, CuTime::from_nanos(70));
2382        keyframe
2383            .add_frozen_task(&CountingSnapshot {
2384                calls: &calls,
2385                value: 11,
2386                fail: false,
2387            })
2388            .unwrap();
2389        let committed_len = keyframe.serialized_tasks.len();
2390
2391        let failing = CountingSnapshot {
2392            calls: &calls,
2393            value: 99,
2394            fail: true,
2395        };
2396        assert!(keyframe.add_frozen_task(&failing).is_err());
2397        assert_eq!(keyframe.serialized_tasks.len(), committed_len);
2398
2399        keyframe
2400            .add_frozen_task(&CountingSnapshot {
2401                calls: &calls,
2402                value: 22,
2403                fail: false,
2404            })
2405            .unwrap();
2406        assert_eq!(calls.get(), 3, "each append must call freeze exactly once");
2407        let first_payload_len = bincode::encode_to_vec(11u32, bincode::config::standard())
2408            .unwrap()
2409            .len();
2410        let second_payload_len = bincode::encode_to_vec(22u32, bincode::config::standard())
2411            .unwrap()
2412            .len();
2413        assert_eq!(
2414            keyframe.serialized_tasks.len(),
2415            KEYFRAME_PAYLOAD_HEADER.len()
2416                + 2 * KEYFRAME_FRAME_HEADER_LEN
2417                + first_payload_len
2418                + second_payload_len,
2419            "component frames carry only a length prefix"
2420        );
2421
2422        let mut frames = KeyFramePayloadReader::new(&keyframe).unwrap();
2423        let mut first = SnapshotValue::default();
2424        thaw_keyframe_component(&mut first, frames.next_frame().unwrap()).unwrap();
2425        let mut second = SnapshotValue::default();
2426        thaw_keyframe_component(&mut second, frames.next_frame().unwrap()).unwrap();
2427        frames.finish().unwrap();
2428        assert_eq!((first.0, second.0), (11, 22));
2429    }
2430
2431    #[cfg(all(feature = "std", feature = "memory_monitoring"))]
2432    #[test]
2433    fn preallocated_keyframe_append_does_not_allocate() {
2434        let calls = Cell::new(0);
2435        let mut keyframe = KeyFrame::new();
2436        keyframe
2437            .serialized_tasks
2438            .try_reserve_exact(MIN_KEYFRAME_CAPTURE_CAPACITY)
2439            .unwrap();
2440        keyframe.reset(3, CuTime::from_nanos(30));
2441
2442        let allocations = crate::monitoring::ScopedAllocCounter::new();
2443        keyframe
2444            .add_frozen_task(&CountingSnapshot {
2445                calls: &calls,
2446                value: 42,
2447                fail: false,
2448            })
2449            .unwrap();
2450
2451        assert_eq!(allocations.allocated(), 0);
2452        assert_eq!(calls.get(), 1);
2453    }
2454
2455    #[test]
2456    fn keyframe_reader_rejects_legacy_payload_clearly() {
2457        let keyframe = KeyFrame {
2458            culistid: 0,
2459            timestamp: CuTime::default(),
2460            serialized_tasks: vec![0, 1, 2],
2461        };
2462        let error = match KeyFramePayloadReader::new(&keyframe) {
2463            Ok(_) => panic!("legacy payload unexpectedly accepted"),
2464            Err(error) => error,
2465        };
2466        assert!(error.to_string().contains("legacy keyframe payload"));
2467    }
2468
2469    #[derive(Reflect)]
2470    pub struct TestSource {}
2471
2472    impl Freezable for TestSource {}
2473
2474    impl CuSrcTask for TestSource {
2475        type Resources<'r> = ();
2476        type Output<'m> = ();
2477        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
2478        where
2479            Self: Sized,
2480        {
2481            Ok(Self {})
2482        }
2483
2484        fn process(&mut self, _ctx: &CuContext, _empty_msg: &mut Self::Output<'_>) -> CuResult<()> {
2485            Ok(())
2486        }
2487    }
2488
2489    #[derive(Reflect)]
2490    pub struct TestSink {}
2491
2492    impl Freezable for TestSink {}
2493
2494    impl CuSinkTask for TestSink {
2495        type Resources<'r> = ();
2496        type Input<'m> = ();
2497
2498        fn new(_config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self>
2499        where
2500            Self: Sized,
2501        {
2502            Ok(Self {})
2503        }
2504
2505        fn process(&mut self, _ctx: &CuContext, _input: &Self::Input<'_>) -> CuResult<()> {
2506            Ok(())
2507        }
2508    }
2509
2510    // Those should be generated by the derive macro
2511    type Tasks = (TestSource, TestSink);
2512    type TestRuntime = CuRuntime<Tasks, (), Msgs, NoMonitor, 2>;
2513    const TEST_NBCL: usize = 2;
2514
2515    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
2516    struct Msgs(());
2517
2518    impl ErasedCuStampedDataSet for Msgs {
2519        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
2520            Vec::new()
2521        }
2522    }
2523
2524    impl MatchingTasks for Msgs {
2525        fn get_all_task_ids() -> &'static [&'static str] {
2526            &[]
2527        }
2528    }
2529
2530    impl CuListZeroedInit for Msgs {
2531        fn init_zeroed(&mut self) {}
2532    }
2533
2534    #[derive(Debug, Encode, Decode, Serialize, Deserialize, Default)]
2535    struct IntMsgs(i32);
2536
2537    impl ErasedCuStampedDataSet for IntMsgs {
2538        fn cumsgs(&self) -> Vec<&dyn ErasedCuStampedData> {
2539            Vec::new()
2540        }
2541    }
2542
2543    impl MatchingTasks for IntMsgs {
2544        fn get_all_task_ids() -> &'static [&'static str] {
2545            &[]
2546        }
2547    }
2548
2549    impl CuListZeroedInit for IntMsgs {
2550        fn init_zeroed(&mut self) {}
2551    }
2552
2553    #[cfg(feature = "std")]
2554    fn tasks_instanciator(
2555        all_instances_configs: Vec<Option<&ComponentConfig>>,
2556        _resources: &mut ResourceManager,
2557        _thread_pools: &[Option<Arc<rayon::ThreadPool>>],
2558    ) -> CuResult<Tasks> {
2559        Ok((
2560            TestSource::new(all_instances_configs[0], ())?,
2561            TestSink::new(all_instances_configs[1], ())?,
2562        ))
2563    }
2564
2565    #[cfg(not(feature = "std"))]
2566    fn tasks_instanciator(
2567        all_instances_configs: Vec<Option<&ComponentConfig>>,
2568        _resources: &mut ResourceManager,
2569    ) -> CuResult<Tasks> {
2570        Ok((
2571            TestSource::new(all_instances_configs[0], ())?,
2572            TestSink::new(all_instances_configs[1], ())?,
2573        ))
2574    }
2575
2576    fn monitor_instanciator(
2577        _config: &CuConfig,
2578        metadata: CuMonitoringMetadata,
2579        runtime: CuMonitoringRuntime,
2580    ) -> NoMonitor {
2581        NoMonitor::new(metadata, runtime).expect("NoMonitor::new should never fail")
2582    }
2583
2584    fn bridges_instanciator(_config: &CuConfig, _resources: &mut ResourceManager) -> CuResult<()> {
2585        Ok(())
2586    }
2587
2588    fn resources_instanciator(_config: &CuConfig) -> CuResult<ResourceManager> {
2589        Ok(ResourceManager::new(&[]))
2590    }
2591
2592    #[derive(Debug)]
2593    struct FakeWriter {}
2594
2595    impl<E: Encode> WriteStream<E> for FakeWriter {
2596        fn log(&mut self, _obj: &E) -> CuResult<()> {
2597            Ok(())
2598        }
2599    }
2600
2601    #[cfg(not(feature = "async-cl-io"))]
2602    #[derive(Debug)]
2603    struct RecordingSyncWriter {
2604        ids: Arc<Mutex<Vec<u64>>>,
2605        last_log_bytes: usize,
2606        fail_on: Option<u64>,
2607    }
2608
2609    #[cfg(not(feature = "async-cl-io"))]
2610    impl WriteStream<CopperList<IntMsgs>> for RecordingSyncWriter {
2611        fn log(&mut self, culist: &CopperList<IntMsgs>) -> CuResult<()> {
2612            self.ids.lock().unwrap().push(culist.id);
2613            if self.fail_on == Some(culist.id) {
2614                return Err(CuError::from(format!(
2615                    "logger failed for CopperList #{}",
2616                    culist.id
2617                )));
2618            }
2619            Ok(())
2620        }
2621
2622        fn last_log_bytes(&self) -> Option<usize> {
2623            Some(self.last_log_bytes)
2624        }
2625    }
2626
2627    #[test]
2628    fn test_runtime_instantiation() {
2629        let mut config = CuConfig::default();
2630        let graph = config.get_graph_mut(None).unwrap();
2631        graph.add_node(Node::new("a", "TestSource")).unwrap();
2632        graph.add_node(Node::new("b", "TestSink")).unwrap();
2633        graph.connect(0, 1, "()").unwrap();
2634        let runtime: CuResult<TestRuntime> =
2635            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
2636                RobotClock::default(),
2637                &config,
2638                crate::config::DEFAULT_MISSION_ID,
2639                CuRuntimeParts::new(
2640                    tasks_instanciator,
2641                    &[],
2642                    &[],
2643                    #[cfg(all(feature = "std", feature = "parallel-rt"))]
2644                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
2645                    monitor_instanciator,
2646                    bridges_instanciator,
2647                ),
2648                FakeWriter {},
2649                FakeWriter {},
2650            )
2651            .try_with_resources_instantiator(resources_instanciator)
2652            .and_then(|builder| builder.build());
2653        assert!(runtime.is_ok());
2654    }
2655
2656    #[test]
2657    fn test_rate_target_period_rejects_zero() {
2658        let err = rate_target_period(0).expect_err("zero rate target should fail");
2659        assert!(
2660            err.to_string()
2661                .contains("Runtime rate target cannot be zero"),
2662            "unexpected error: {err}"
2663        );
2664    }
2665
2666    #[test]
2667    fn test_loop_rate_limiter_advances_to_next_period_when_on_time() {
2668        let (clock, mock) = RobotClock::mock();
2669        let mut limiter = LoopRateLimiter::from_rate_target_hz(100, &clock).unwrap();
2670        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(10_000_000));
2671
2672        mock.set_value(10_000_000);
2673        limiter.mark_tick(&clock);
2674
2675        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(20_000_000));
2676    }
2677
2678    #[test]
2679    fn test_loop_rate_limiter_skips_missed_periods_without_resetting_phase() {
2680        let (clock, mock) = RobotClock::mock();
2681        let mut limiter = LoopRateLimiter::from_rate_target_hz(100, &clock).unwrap();
2682
2683        mock.set_value(35_000_000);
2684        limiter.mark_tick(&clock);
2685
2686        assert_eq!(limiter.next_deadline(), CuTime::from_nanos(40_000_000));
2687    }
2688
2689    #[cfg(all(feature = "std", feature = "high-precision-limiter"))]
2690    #[test]
2691    fn test_loop_rate_limiter_spin_window_is_fixed_scheduler_window() {
2692        let (clock, _) = RobotClock::mock();
2693        let limiter = LoopRateLimiter::from_rate_target_hz(1_000, &clock).unwrap();
2694        assert_eq!(limiter.spin_window(), CuDuration::from(200_000));
2695
2696        let fast = LoopRateLimiter::from_rate_target_hz(10_000, &clock).unwrap();
2697        assert_eq!(fast.spin_window(), CuDuration::from(200_000));
2698    }
2699
2700    #[cfg(not(feature = "async-cl-io"))]
2701    #[test]
2702    fn test_copperlists_manager_lifecycle() {
2703        let mut config = CuConfig::default();
2704        let graph = config.get_graph_mut(None).unwrap();
2705        graph.add_node(Node::new("a", "TestSource")).unwrap();
2706        graph.add_node(Node::new("b", "TestSink")).unwrap();
2707        graph.connect(0, 1, "()").unwrap();
2708
2709        let mut runtime: TestRuntime =
2710            CuRuntimeBuilder::<Tasks, (), Msgs, NoMonitor, TEST_NBCL, _, _, _, _, _>::new(
2711                RobotClock::default(),
2712                &config,
2713                crate::config::DEFAULT_MISSION_ID,
2714                CuRuntimeParts::new(
2715                    tasks_instanciator,
2716                    &[],
2717                    &[],
2718                    #[cfg(all(feature = "std", feature = "parallel-rt"))]
2719                    &crate::parallel_rt::DISABLED_PARALLEL_RT_METADATA,
2720                    monitor_instanciator,
2721                    bridges_instanciator,
2722                ),
2723                FakeWriter {},
2724                FakeWriter {},
2725            )
2726            .try_with_resources_instantiator(resources_instanciator)
2727            .and_then(|builder| builder.build())
2728            .unwrap();
2729
2730        // Now emulates the generated runtime
2731        {
2732            let copperlists = &mut runtime.copperlists_manager;
2733            let culist0 = copperlists
2734                .create()
2735                .expect("Ran out of space for copper lists");
2736            let id = culist0.id;
2737            assert_eq!(id, 0);
2738            culist0.change_state(CopperListState::Processing);
2739            assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2740        }
2741
2742        {
2743            let copperlists = &mut runtime.copperlists_manager;
2744            let culist1 = copperlists
2745                .create()
2746                .expect("Ran out of space for copper lists");
2747            let id = culist1.id;
2748            assert_eq!(id, 1);
2749            culist1.change_state(CopperListState::Processing);
2750            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2751        }
2752
2753        {
2754            let copperlists = &mut runtime.copperlists_manager;
2755            let culist2 = copperlists.create();
2756            assert!(culist2.is_err());
2757            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2758            // Free in order, should let the top of the stack be serialized and freed.
2759            let _ = copperlists.end_of_processing(1);
2760            assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2761        }
2762
2763        // Readd a CL
2764        {
2765            let copperlists = &mut runtime.copperlists_manager;
2766            let culist2 = copperlists
2767                .create()
2768                .expect("Ran out of space for copper lists");
2769            let id = culist2.id;
2770            assert_eq!(id, 2);
2771            culist2.change_state(CopperListState::Processing);
2772            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2773            // Free out of order, the #0 first
2774            let _ = copperlists.end_of_processing(0);
2775            // Should not free up the top of the stack
2776            assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2777
2778            // Free up the top of the stack
2779            let _ = copperlists.end_of_processing(2);
2780            // This should free up 2 CLs
2781
2782            assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
2783        }
2784    }
2785
2786    #[cfg(not(feature = "async-cl-io"))]
2787    #[test]
2788    fn test_sync_copperlists_accessors_passthrough_to_inner_manager() {
2789        let mut copperlists = SyncCopperListsManager::<IntMsgs, 2>::new(None).unwrap();
2790
2791        assert_eq!(copperlists.next_cl_id(), 0);
2792        assert_eq!(copperlists.last_cl_id(), 0);
2793        assert!(copperlists.peek().is_none());
2794
2795        {
2796            let culist = copperlists.create().unwrap();
2797            culist.msgs.0 = 11;
2798            assert_eq!(culist.id, 0);
2799            assert_eq!(culist.get_state(), CopperListState::Initialized);
2800        }
2801
2802        assert_eq!(copperlists.next_cl_id(), 1);
2803        assert_eq!(copperlists.last_cl_id(), 0);
2804        let peeked = copperlists.peek().unwrap();
2805        assert_eq!(peeked.id, 0);
2806        assert_eq!(peeked.msgs.0, 11);
2807        assert_eq!(peeked.get_state(), CopperListState::Initialized);
2808    }
2809
2810    #[cfg(not(feature = "async-cl-io"))]
2811    #[test]
2812    fn test_sync_reclaimed_slot_reuse_reinitializes_state_but_preserves_payload_storage() {
2813        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2814
2815        {
2816            let culist = copperlists.create().unwrap();
2817            culist.msgs.0 = 41;
2818            culist.change_state(CopperListState::Processing);
2819            assert_eq!(culist.id, 0);
2820        }
2821
2822        copperlists.end_of_processing(0).unwrap();
2823        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2824
2825        let reused = copperlists.create().unwrap();
2826        assert_eq!(reused.id, 1);
2827        assert_eq!(reused.get_state(), CopperListState::Initialized);
2828        assert_eq!(reused.msgs.0, 41);
2829    }
2830
2831    #[cfg(all(not(feature = "async-cl-io"), debug_assertions))]
2832    #[test]
2833    #[should_panic(expected = "sync end_of_processing expected exactly one active CopperList #99")]
2834    fn test_sync_end_of_processing_unknown_id_panics_in_debug() {
2835        let mut copperlists = SyncCopperListsManager::<IntMsgs, 2>::new(None).unwrap();
2836
2837        {
2838            let culist = copperlists.create().unwrap();
2839            culist.msgs.0 = 10;
2840            culist.change_state(CopperListState::Processing);
2841        }
2842        {
2843            let culist = copperlists.create().unwrap();
2844            culist.msgs.0 = 20;
2845            culist.change_state(CopperListState::Processing);
2846        }
2847
2848        let _ = copperlists.end_of_processing(99);
2849    }
2850
2851    #[cfg(all(not(feature = "async-cl-io"), debug_assertions))]
2852    #[test]
2853    #[should_panic(expected = "sync end_of_processing expected CopperList #0 to be Processing")]
2854    fn test_sync_end_of_processing_wrong_state_panics_in_debug() {
2855        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2856
2857        {
2858            let culist = copperlists.create().unwrap();
2859            culist.msgs.0 = 10;
2860            assert_eq!(culist.get_state(), CopperListState::Initialized);
2861        }
2862
2863        let _ = copperlists.end_of_processing(0);
2864    }
2865
2866    #[cfg(not(feature = "async-cl-io"))]
2867    #[test]
2868    fn test_sync_end_of_processing_serializes_done_suffix_from_newest_to_oldest() {
2869        let ids = Arc::new(Mutex::new(Vec::new()));
2870        let mut copperlists =
2871            SyncCopperListsManager::<IntMsgs, 2>::new(Some(Box::new(RecordingSyncWriter {
2872                ids: ids.clone(),
2873                last_log_bytes: 17,
2874                fail_on: None,
2875            })))
2876            .unwrap();
2877
2878        {
2879            let culist = copperlists.create().unwrap();
2880            culist.msgs.0 = 10;
2881            culist.change_state(CopperListState::Processing);
2882        }
2883        {
2884            let culist = copperlists.create().unwrap();
2885            culist.msgs.0 = 20;
2886            culist.change_state(CopperListState::Processing);
2887        }
2888
2889        copperlists.end_of_processing(0).unwrap();
2890        assert!(ids.lock().unwrap().is_empty());
2891        assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2892
2893        copperlists.end_of_processing(1).unwrap();
2894
2895        assert_eq!(*ids.lock().unwrap(), vec![1, 0]);
2896        assert_eq!(copperlists.available_copper_lists().unwrap(), 2);
2897    }
2898
2899    #[cfg(not(feature = "async-cl-io"))]
2900    #[test]
2901    fn test_sync_end_of_processing_updates_logger_counters_on_success() {
2902        let ids = Arc::new(Mutex::new(Vec::new()));
2903        let mut copperlists =
2904            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSyncWriter {
2905                ids: ids.clone(),
2906                last_log_bytes: 17,
2907                fail_on: None,
2908            })))
2909            .unwrap();
2910        let io_cache = crate::monitoring::CuMsgIoCache::<1>::default();
2911
2912        {
2913            let culist = copperlists.create().unwrap();
2914            culist.msgs.0 = 10;
2915            culist.change_state(CopperListState::Processing);
2916        }
2917
2918        {
2919            let capture = crate::monitoring::start_copperlist_io_capture(&io_cache);
2920            capture.select_slot(0);
2921            crate::monitoring::record_payload_handle_bytes(32);
2922        }
2923
2924        copperlists.end_of_processing(0).unwrap();
2925
2926        assert_eq!(*ids.lock().unwrap(), vec![0]);
2927        assert_eq!(copperlists.last_encoded_bytes, 17);
2928        assert_eq!(copperlists.last_handle_bytes, 32);
2929        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
2930    }
2931
2932    #[cfg(not(feature = "async-cl-io"))]
2933    #[test]
2934    fn test_sync_end_of_processing_preserves_slot_on_logger_error() {
2935        let ids = Arc::new(Mutex::new(Vec::new()));
2936        let mut copperlists =
2937            SyncCopperListsManager::<IntMsgs, 1>::new(Some(Box::new(RecordingSyncWriter {
2938                ids: ids.clone(),
2939                last_log_bytes: 17,
2940                fail_on: Some(0),
2941            })))
2942            .unwrap();
2943
2944        {
2945            let culist = copperlists.create().unwrap();
2946            culist.change_state(CopperListState::Processing);
2947        }
2948
2949        let err = copperlists.end_of_processing(0).unwrap_err();
2950
2951        assert!(
2952            err.to_string().contains("logger failed for CopperList #0"),
2953            "unexpected error: {err}"
2954        );
2955        assert_eq!(*ids.lock().unwrap(), vec![0]);
2956        assert_eq!(copperlists.available_copper_lists().unwrap(), 0);
2957        assert_eq!(copperlists.last_encoded_bytes, 0);
2958        assert_eq!(copperlists.last_handle_bytes, 0);
2959
2960        let peeked = copperlists.peek().unwrap();
2961        assert_eq!(peeked.id, 0);
2962        assert_eq!(peeked.get_state(), CopperListState::BeingSerialized);
2963    }
2964
2965    #[cfg(all(not(feature = "async-cl-io"), feature = "std", debug_assertions))]
2966    #[test]
2967    #[should_panic(
2968        expected = "sync boxed end_of_processing expected CopperList #7 to be Processing"
2969    )]
2970    fn test_sync_end_of_processing_boxed_wrong_state_panics_in_debug() {
2971        let mut copperlists = SyncCopperListsManager::<IntMsgs, 1>::new(None).unwrap();
2972        let culist = Box::new(CopperList::new(7, IntMsgs::default()));
2973
2974        let _ = copperlists.end_of_processing_boxed(culist);
2975    }
2976
2977    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2978    #[derive(Debug, Default)]
2979    struct RecordingWriter {
2980        ids: Arc<Mutex<Vec<u64>>>,
2981    }
2982
2983    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2984    impl WriteStream<CopperList<Msgs>> for RecordingWriter {
2985        fn log(&mut self, culist: &CopperList<Msgs>) -> CuResult<()> {
2986            self.ids.lock().unwrap().push(culist.id);
2987            std::thread::sleep(std::time::Duration::from_millis(2));
2988            Ok(())
2989        }
2990    }
2991
2992    #[cfg(all(feature = "std", feature = "async-cl-io"))]
2993    #[test]
2994    fn test_async_copperlists_manager_flushes_in_order() {
2995        let ids = Arc::new(Mutex::new(Vec::new()));
2996        let mut copperlists = CopperListsManager::<Msgs, 4>::new(Some(Box::new(RecordingWriter {
2997            ids: ids.clone(),
2998        })))
2999        .unwrap();
3000
3001        for expected_id in 0..4 {
3002            let culist = copperlists.create().unwrap();
3003            assert_eq!(culist.id, expected_id);
3004            culist.change_state(CopperListState::Processing);
3005            copperlists.end_of_processing(expected_id).unwrap();
3006        }
3007
3008        copperlists.finish_pending().unwrap();
3009        assert_eq!(copperlists.available_copper_lists().unwrap(), 4);
3010        assert_eq!(*ids.lock().unwrap(), vec![0, 1, 2, 3]);
3011    }
3012
3013    #[cfg(all(feature = "std", feature = "async-cl-io"))]
3014    #[test]
3015    fn test_async_create_reinitializes_reclaimed_slot_state_but_preserves_payload_storage() {
3016        let mut copperlists = CopperListsManager::<IntMsgs, 1>::new(None).unwrap();
3017
3018        {
3019            let culist = copperlists.create().unwrap();
3020            assert_eq!(culist.id, 0);
3021            assert_eq!(culist.get_state(), CopperListState::Initialized);
3022            culist.msgs.0 = 41;
3023            culist.change_state(CopperListState::Processing);
3024        }
3025
3026        copperlists.end_of_processing(0).unwrap();
3027        assert_eq!(copperlists.available_copper_lists().unwrap(), 1);
3028
3029        let reused = copperlists.create().unwrap();
3030        assert_eq!(reused.id, 1);
3031        assert_eq!(reused.get_state(), CopperListState::Initialized);
3032        assert_eq!(reused.msgs.0, 41);
3033    }
3034
3035    #[cfg(all(feature = "std", feature = "async-cl-io", debug_assertions))]
3036    #[test]
3037    #[should_panic(expected = "async end_of_processing expected CopperList #0 to be Processing")]
3038    fn test_async_end_of_processing_wrong_state_panics_in_debug() {
3039        let mut copperlists = CopperListsManager::<IntMsgs, 1>::new(None).unwrap();
3040
3041        let culist = copperlists.create().unwrap();
3042        assert_eq!(culist.id, 0);
3043        assert_eq!(culist.get_state(), CopperListState::Initialized);
3044
3045        let _ = copperlists.end_of_processing(0);
3046    }
3047
3048    #[test]
3049    fn test_runtime_task_input_order() {
3050        let mut config = CuConfig::default();
3051        let graph = config.get_graph_mut(None).unwrap();
3052        let src1_id = graph.add_node(Node::new("a", "Source1")).unwrap();
3053        let src2_id = graph.add_node(Node::new("b", "Source2")).unwrap();
3054        let sink_id = graph.add_node(Node::new("c", "Sink")).unwrap();
3055
3056        assert_eq!(src1_id, 0);
3057        assert_eq!(src2_id, 1);
3058
3059        // note that the source2 connection is before the source1
3060        let src1_type = "src1_type";
3061        let src2_type = "src2_type";
3062        graph.connect(src2_id, sink_id, src2_type).unwrap();
3063        graph.connect(src1_id, sink_id, src1_type).unwrap();
3064
3065        let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
3066        let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
3067        // the edge id depends on the order the connection is created, not
3068        // on the node id, and that is what determines the input order
3069        assert_eq!(src1_edge_id, 1);
3070        assert_eq!(src2_edge_id, 0);
3071
3072        let runtime = compute_runtime_plan(graph).unwrap();
3073        let sink_step = runtime
3074            .steps
3075            .iter()
3076            .find_map(|step| match step {
3077                CuExecutionUnit::Step(step) if step.node_id == sink_id => Some(step),
3078                _ => None,
3079            })
3080            .unwrap();
3081
3082        // since the src2 connection was added before src1 connection, the src2 type should be
3083        // first
3084        assert_eq!(sink_step.input_msg_indices_types[0].msg_type, src2_type);
3085        assert_eq!(sink_step.input_msg_indices_types[1].msg_type, src1_type);
3086    }
3087
3088    #[test]
3089    fn test_runtime_output_ports_unique_ordered() {
3090        let mut config = CuConfig::default();
3091        let graph = config.get_graph_mut(None).unwrap();
3092        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
3093        let dst_a_id = graph.add_node(Node::new("dst_a", "SinkA")).unwrap();
3094        let dst_b_id = graph.add_node(Node::new("dst_b", "SinkB")).unwrap();
3095        let dst_a2_id = graph.add_node(Node::new("dst_a2", "SinkA2")).unwrap();
3096        let dst_c_id = graph.add_node(Node::new("dst_c", "SinkC")).unwrap();
3097
3098        graph.connect(src_id, dst_a_id, "msg::A").unwrap();
3099        graph.connect(src_id, dst_b_id, "msg::B").unwrap();
3100        graph.connect(src_id, dst_a2_id, "msg::A").unwrap();
3101        graph.connect(src_id, dst_c_id, "msg::C").unwrap();
3102
3103        let runtime = compute_runtime_plan(graph).unwrap();
3104        let src_step = runtime
3105            .steps
3106            .iter()
3107            .find_map(|step| match step {
3108                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3109                _ => None,
3110            })
3111            .unwrap();
3112
3113        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3114        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B", "msg::C"]);
3115
3116        let dst_a_step = runtime
3117            .steps
3118            .iter()
3119            .find_map(|step| match step {
3120                CuExecutionUnit::Step(step) if step.node_id == dst_a_id => Some(step),
3121                _ => None,
3122            })
3123            .unwrap();
3124        let dst_b_step = runtime
3125            .steps
3126            .iter()
3127            .find_map(|step| match step {
3128                CuExecutionUnit::Step(step) if step.node_id == dst_b_id => Some(step),
3129                _ => None,
3130            })
3131            .unwrap();
3132        let dst_a2_step = runtime
3133            .steps
3134            .iter()
3135            .find_map(|step| match step {
3136                CuExecutionUnit::Step(step) if step.node_id == dst_a2_id => Some(step),
3137                _ => None,
3138            })
3139            .unwrap();
3140        let dst_c_step = runtime
3141            .steps
3142            .iter()
3143            .find_map(|step| match step {
3144                CuExecutionUnit::Step(step) if step.node_id == dst_c_id => Some(step),
3145                _ => None,
3146            })
3147            .unwrap();
3148
3149        assert_eq!(dst_a_step.input_msg_indices_types[0].src_port, 0);
3150        assert_eq!(dst_b_step.input_msg_indices_types[0].src_port, 1);
3151        assert_eq!(dst_a2_step.input_msg_indices_types[0].src_port, 0);
3152        assert_eq!(dst_c_step.input_msg_indices_types[0].src_port, 2);
3153    }
3154
3155    #[test]
3156    fn test_runtime_output_ports_fanout_single() {
3157        let mut config = CuConfig::default();
3158        let graph = config.get_graph_mut(None).unwrap();
3159        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
3160        let dst_a_id = graph.add_node(Node::new("dst_a", "SinkA")).unwrap();
3161        let dst_b_id = graph.add_node(Node::new("dst_b", "SinkB")).unwrap();
3162
3163        graph.connect(src_id, dst_a_id, "i32").unwrap();
3164        graph.connect(src_id, dst_b_id, "i32").unwrap();
3165
3166        let runtime = compute_runtime_plan(graph).unwrap();
3167        let src_step = runtime
3168            .steps
3169            .iter()
3170            .find_map(|step| match step {
3171                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3172                _ => None,
3173            })
3174            .unwrap();
3175
3176        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3177        assert_eq!(output_pack.msg_types, vec!["i32"]);
3178    }
3179
3180    #[test]
3181    fn test_runtime_output_ports_include_nc_outputs() {
3182        let mut config = CuConfig::default();
3183        let graph = config.get_graph_mut(None).unwrap();
3184        let src_id = graph.add_node(Node::new("src", "Source")).unwrap();
3185        let dst_id = graph.add_node(Node::new("dst", "Sink")).unwrap();
3186        graph.connect(src_id, dst_id, "msg::A").unwrap();
3187        graph
3188            .get_node_mut(src_id)
3189            .expect("missing source node")
3190            .add_nc_output("msg::B", usize::MAX);
3191
3192        let runtime = compute_runtime_plan(graph).unwrap();
3193        let src_step = runtime
3194            .steps
3195            .iter()
3196            .find_map(|step| match step {
3197                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3198                _ => None,
3199            })
3200            .unwrap();
3201        let dst_step = runtime
3202            .steps
3203            .iter()
3204            .find_map(|step| match step {
3205                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3206                _ => None,
3207            })
3208            .unwrap();
3209
3210        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3211        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
3212        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 0);
3213    }
3214
3215    #[test]
3216    fn test_runtime_plan_infers_regular_task_when_outputs_are_nc_only() {
3217        let txt = r#"(
3218            tasks: [
3219                (id: "src", type: "a"),
3220                (id: "regular", type: "b"),
3221            ],
3222            cnx: [
3223                (src: "src", dst: "regular", msg: "msg::A"),
3224                (src: "regular", dst: "__nc__", msg: "msg::B"),
3225            ]
3226        )"#;
3227        let config = CuConfig::deserialize_ron(txt).unwrap();
3228        let graph = config.get_graph(None).unwrap();
3229        let regular_id = graph.get_node_id_by_name("regular").unwrap();
3230
3231        let runtime = compute_runtime_plan(graph).unwrap();
3232        let regular_step = runtime
3233            .steps
3234            .iter()
3235            .find_map(|step| match step {
3236                CuExecutionUnit::Step(step) if step.node_id == regular_id => Some(step),
3237                _ => None,
3238            })
3239            .unwrap();
3240
3241        assert_eq!(regular_step.task_type, CuTaskType::Regular);
3242        assert_eq!(
3243            regular_step.output_msg_pack.as_ref().unwrap().msg_types,
3244            vec!["msg::B"]
3245        );
3246    }
3247
3248    #[test]
3249    fn test_runtime_output_ports_respect_connection_order_with_nc() {
3250        let txt = r#"(
3251            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
3252            cnx: [
3253                (src: "src", dst: "__nc__", msg: "msg::A"),
3254                (src: "src", dst: "sink", msg: "msg::B"),
3255            ]
3256        )"#;
3257        let config = CuConfig::deserialize_ron(txt).unwrap();
3258        let graph = config.get_graph(None).unwrap();
3259        let src_id = graph.get_node_id_by_name("src").unwrap();
3260        let dst_id = graph.get_node_id_by_name("sink").unwrap();
3261
3262        let runtime = compute_runtime_plan(graph).unwrap();
3263        let src_step = runtime
3264            .steps
3265            .iter()
3266            .find_map(|step| match step {
3267                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3268                _ => None,
3269            })
3270            .unwrap();
3271        let dst_step = runtime
3272            .steps
3273            .iter()
3274            .find_map(|step| match step {
3275                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3276                _ => None,
3277            })
3278            .unwrap();
3279
3280        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3281        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
3282        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
3283    }
3284
3285    #[cfg(feature = "std")]
3286    #[test]
3287    fn test_runtime_output_ports_respect_connection_order_with_nc_from_file() {
3288        let txt = r#"(
3289            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
3290            cnx: [
3291                (src: "src", dst: "__nc__", msg: "msg::A"),
3292                (src: "src", dst: "sink", msg: "msg::B"),
3293            ]
3294        )"#;
3295        let tmp = tempfile::NamedTempFile::new().unwrap();
3296        std::fs::write(tmp.path(), txt).unwrap();
3297        let config = crate::config::read_configuration(tmp.path().to_str().unwrap()).unwrap();
3298        let graph = config.get_graph(None).unwrap();
3299        let src_id = graph.get_node_id_by_name("src").unwrap();
3300        let dst_id = graph.get_node_id_by_name("sink").unwrap();
3301
3302        let runtime = compute_runtime_plan(graph).unwrap();
3303        let src_step = runtime
3304            .steps
3305            .iter()
3306            .find_map(|step| match step {
3307                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3308                _ => None,
3309            })
3310            .unwrap();
3311        let dst_step = runtime
3312            .steps
3313            .iter()
3314            .find_map(|step| match step {
3315                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3316                _ => None,
3317            })
3318            .unwrap();
3319
3320        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3321        assert_eq!(output_pack.msg_types, vec!["msg::A", "msg::B"]);
3322        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
3323    }
3324
3325    #[test]
3326    fn test_runtime_output_ports_respect_connection_order_with_nc_primitives() {
3327        let txt = r#"(
3328            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
3329            cnx: [
3330                (src: "src", dst: "__nc__", msg: "i32"),
3331                (src: "src", dst: "sink", msg: "bool"),
3332            ]
3333        )"#;
3334        let config = CuConfig::deserialize_ron(txt).unwrap();
3335        let graph = config.get_graph(None).unwrap();
3336        let src_id = graph.get_node_id_by_name("src").unwrap();
3337        let dst_id = graph.get_node_id_by_name("sink").unwrap();
3338
3339        let runtime = compute_runtime_plan(graph).unwrap();
3340        let src_step = runtime
3341            .steps
3342            .iter()
3343            .find_map(|step| match step {
3344                CuExecutionUnit::Step(step) if step.node_id == src_id => Some(step),
3345                _ => None,
3346            })
3347            .unwrap();
3348        let dst_step = runtime
3349            .steps
3350            .iter()
3351            .find_map(|step| match step {
3352                CuExecutionUnit::Step(step) if step.node_id == dst_id => Some(step),
3353                _ => None,
3354            })
3355            .unwrap();
3356
3357        let output_pack = src_step.output_msg_pack.as_ref().unwrap();
3358        assert_eq!(output_pack.msg_types, vec!["i32", "bool"]);
3359        assert_eq!(dst_step.input_msg_indices_types[0].src_port, 1);
3360    }
3361
3362    #[test]
3363    fn test_runtime_plan_diamond_case1() {
3364        // more complex topology that tripped the scheduler
3365        let mut config = CuConfig::default();
3366        let graph = config.get_graph_mut(None).unwrap();
3367        let cam0_id = graph
3368            .add_node(Node::new("cam0", "tasks::IntegerSrcTask"))
3369            .unwrap();
3370        let inf0_id = graph
3371            .add_node(Node::new("inf0", "tasks::Integer2FloatTask"))
3372            .unwrap();
3373        let broadcast_id = graph
3374            .add_node(Node::new("broadcast", "tasks::MergingSinkTask"))
3375            .unwrap();
3376
3377        // case 1 order
3378        graph.connect(cam0_id, broadcast_id, "i32").unwrap();
3379        graph.connect(cam0_id, inf0_id, "i32").unwrap();
3380        graph.connect(inf0_id, broadcast_id, "f32").unwrap();
3381
3382        let edge_cam0_to_broadcast = *graph.get_src_edges(cam0_id).unwrap().first().unwrap();
3383        let edge_cam0_to_inf0 = graph.get_src_edges(cam0_id).unwrap()[1];
3384
3385        assert_eq!(edge_cam0_to_inf0, 0);
3386        assert_eq!(edge_cam0_to_broadcast, 1);
3387
3388        let runtime = compute_runtime_plan(graph).unwrap();
3389        let broadcast_step = runtime
3390            .steps
3391            .iter()
3392            .find_map(|step| match step {
3393                CuExecutionUnit::Step(step) if step.node_id == broadcast_id => Some(step),
3394                _ => None,
3395            })
3396            .unwrap();
3397
3398        assert_eq!(broadcast_step.input_msg_indices_types[0].msg_type, "i32");
3399        assert_eq!(broadcast_step.input_msg_indices_types[1].msg_type, "f32");
3400    }
3401
3402    #[test]
3403    fn test_runtime_plan_diamond_case2() {
3404        // more complex topology that tripped the scheduler variation 2
3405        let mut config = CuConfig::default();
3406        let graph = config.get_graph_mut(None).unwrap();
3407        let cam0_id = graph
3408            .add_node(Node::new("cam0", "tasks::IntegerSrcTask"))
3409            .unwrap();
3410        let inf0_id = graph
3411            .add_node(Node::new("inf0", "tasks::Integer2FloatTask"))
3412            .unwrap();
3413        let broadcast_id = graph
3414            .add_node(Node::new("broadcast", "tasks::MergingSinkTask"))
3415            .unwrap();
3416
3417        // case 2 order
3418        graph.connect(cam0_id, inf0_id, "i32").unwrap();
3419        graph.connect(cam0_id, broadcast_id, "i32").unwrap();
3420        graph.connect(inf0_id, broadcast_id, "f32").unwrap();
3421
3422        let edge_cam0_to_inf0 = *graph.get_src_edges(cam0_id).unwrap().first().unwrap();
3423        let edge_cam0_to_broadcast = graph.get_src_edges(cam0_id).unwrap()[1];
3424
3425        assert_eq!(edge_cam0_to_broadcast, 0);
3426        assert_eq!(edge_cam0_to_inf0, 1);
3427
3428        let runtime = compute_runtime_plan(graph).unwrap();
3429        let broadcast_step = runtime
3430            .steps
3431            .iter()
3432            .find_map(|step| match step {
3433                CuExecutionUnit::Step(step) if step.node_id == broadcast_id => Some(step),
3434                _ => None,
3435            })
3436            .unwrap();
3437
3438        assert_eq!(broadcast_step.input_msg_indices_types[0].msg_type, "i32");
3439        assert_eq!(broadcast_step.input_msg_indices_types[1].msg_type, "f32");
3440    }
3441
3442    // --- anytime plan expansion ---
3443
3444    use crate::config::AnytimeConfig;
3445
3446    fn anytime_node(id: &str, max_refines: Option<u32>) -> Node {
3447        let mut node = Node::new(id, "tasks::AnytimeTask");
3448        node.set_anytime(Some(AnytimeConfig {
3449            max_refines,
3450            ..Default::default()
3451        }));
3452        node
3453    }
3454
3455    /// Renders the plan as `(node_id, phase)` pairs for compact assertions.
3456    fn plan_shape(plan: &CuExecutionLoop) -> Vec<(NodeId, CuStepPhase)> {
3457        plan.steps
3458            .iter()
3459            .map(|unit| match unit {
3460                CuExecutionUnit::Step(step) => (step.node_id, step.phase),
3461                CuExecutionUnit::Loop(_) => panic!("no loops expected"),
3462            })
3463            .collect()
3464    }
3465
3466    /// A manual step, bypassing the planner heuristic so gap placement is
3467    /// deterministic: `inputs`/`output` are copperlist indices.
3468    fn manual_step(node: Node, node_id: NodeId, inputs: &[u32], output: u32) -> CuExecutionUnit {
3469        CuExecutionUnit::Step(Box::new(CuExecutionStep {
3470            node_id,
3471            node,
3472            task_type: CuTaskType::Regular,
3473            phase: CuStepPhase::default(),
3474            input_msg_indices_types: inputs
3475                .iter()
3476                .map(|&culist_index| CuInputMsg {
3477                    culist_index,
3478                    msg_type: "msg::A".to_string(),
3479                    src_port: 0,
3480                    edge_id: 0,
3481                    connection_order: 0,
3482                })
3483                .collect(),
3484            output_msg_pack: Some(CuOutputPack {
3485                culist_index: output,
3486                msg_types: vec!["msg::A".to_string()],
3487            }),
3488        }))
3489    }
3490
3491    #[test]
3492    fn test_anytime_expansion_contiguous_without_gap() {
3493        // src -> any -> sink through the real planner: no independent steps
3494        // between the node and its consumer, so every refine sits before it.
3495        let mut config = CuConfig::default();
3496        let graph = config.get_graph_mut(None).unwrap();
3497        let src_id = graph.add_node(Node::new("src", "tasks::Src")).unwrap();
3498        let any_id = graph.add_node(anytime_node("any", Some(3))).unwrap();
3499        let sink_id = graph.add_node(Node::new("sink", "tasks::Sink")).unwrap();
3500        graph.connect(src_id, any_id, "msg::A").unwrap();
3501        graph.connect(any_id, sink_id, "msg::B").unwrap();
3502
3503        let mut plan = compute_runtime_plan(graph).unwrap();
3504        expand_anytime_steps(&mut plan).unwrap();
3505
3506        assert_eq!(
3507            plan_shape(&plan),
3508            vec![
3509                (src_id, CuStepPhase::Whole),
3510                (any_id, CuStepPhase::AnytimeBase),
3511                (any_id, CuStepPhase::AnytimeRefine),
3512                (any_id, CuStepPhase::AnytimeRefine),
3513                (any_id, CuStepPhase::AnytimeRefine),
3514                (sink_id, CuStepPhase::Whole),
3515            ]
3516        );
3517
3518        // Refine steps read no input and write the base step's output slot.
3519        let (base_pack, refine_steps): (Option<CuOutputPack>, Vec<&CuExecutionStep>) = {
3520            let mut base_pack = None;
3521            let mut refines = Vec::new();
3522            for unit in &plan.steps {
3523                if let CuExecutionUnit::Step(step) = unit {
3524                    match step.phase {
3525                        CuStepPhase::AnytimeBase => base_pack = step.output_msg_pack.clone(),
3526                        CuStepPhase::AnytimeRefine => refines.push(step.as_ref()),
3527                        CuStepPhase::Whole => {}
3528                    }
3529                }
3530            }
3531            (base_pack, refines)
3532        };
3533        let base_pack = base_pack.unwrap();
3534        for refine in refine_steps {
3535            assert!(refine.input_msg_indices_types.is_empty());
3536            let pack = refine.output_msg_pack.as_ref().unwrap();
3537            assert_eq!(pack.culist_index, base_pack.culist_index);
3538        }
3539    }
3540
3541    #[test]
3542    fn test_anytime_expansion_interleaves_with_gap_steps() {
3543        // Manual plan: [any(base at 1), gapA, gapB, consumer], R = 4.
3544        // Expected: base, r, gapA, r, gapB, r, r, consumer.
3545        let mut plan = CuExecutionLoop {
3546            steps: vec![
3547                manual_step(anytime_node("any", Some(4)), 0, &[], 0),
3548                manual_step(Node::new("gap_a", "t"), 1, &[], 1),
3549                manual_step(Node::new("gap_b", "t"), 2, &[], 2),
3550                manual_step(Node::new("consumer", "t"), 3, &[0], 3),
3551            ],
3552            loop_count: None,
3553        };
3554        expand_anytime_steps(&mut plan).unwrap();
3555        assert_eq!(
3556            plan_shape(&plan),
3557            vec![
3558                (0, CuStepPhase::AnytimeBase),
3559                (0, CuStepPhase::AnytimeRefine),
3560                (1, CuStepPhase::Whole),
3561                (0, CuStepPhase::AnytimeRefine),
3562                (2, CuStepPhase::Whole),
3563                (0, CuStepPhase::AnytimeRefine),
3564                (0, CuStepPhase::AnytimeRefine),
3565                (3, CuStepPhase::Whole),
3566            ]
3567        );
3568    }
3569
3570    #[test]
3571    fn test_anytime_expansion_fewer_refines_than_gaps() {
3572        // R = 2 with two gap steps: later gap steps get no quantum after them.
3573        let mut plan = CuExecutionLoop {
3574            steps: vec![
3575                manual_step(anytime_node("any", Some(2)), 0, &[], 0),
3576                manual_step(Node::new("gap_a", "t"), 1, &[], 1),
3577                manual_step(Node::new("gap_b", "t"), 2, &[], 2),
3578                manual_step(Node::new("consumer", "t"), 3, &[0], 3),
3579            ],
3580            loop_count: None,
3581        };
3582        expand_anytime_steps(&mut plan).unwrap();
3583        assert_eq!(
3584            plan_shape(&plan),
3585            vec![
3586                (0, CuStepPhase::AnytimeBase),
3587                (0, CuStepPhase::AnytimeRefine),
3588                (1, CuStepPhase::Whole),
3589                (0, CuStepPhase::AnytimeRefine),
3590                (2, CuStepPhase::Whole),
3591                (3, CuStepPhase::Whole),
3592            ]
3593        );
3594    }
3595
3596    #[test]
3597    fn test_anytime_expansion_without_consumer() {
3598        // No step consumes the node's output: refines sit right after the base.
3599        let mut plan = CuExecutionLoop {
3600            steps: vec![
3601                manual_step(anytime_node("any", Some(2)), 0, &[], 0),
3602                manual_step(Node::new("other", "t"), 1, &[], 1),
3603            ],
3604            loop_count: None,
3605        };
3606        expand_anytime_steps(&mut plan).unwrap();
3607        assert_eq!(
3608            plan_shape(&plan),
3609            vec![
3610                (0, CuStepPhase::AnytimeBase),
3611                (0, CuStepPhase::AnytimeRefine),
3612                (0, CuStepPhase::AnytimeRefine),
3613                (1, CuStepPhase::Whole),
3614            ]
3615        );
3616    }
3617
3618    #[test]
3619    fn test_anytime_expansion_two_nodes_interleave() {
3620        // Two anytime nodes: each expands independently; the second node's base
3621        // and quanta land in the first node's gap and vice versa.
3622        let mut plan = CuExecutionLoop {
3623            steps: vec![
3624                manual_step(anytime_node("any_a", Some(2)), 0, &[], 0),
3625                manual_step(anytime_node("any_b", Some(2)), 1, &[], 1),
3626                manual_step(Node::new("consumer", "t"), 2, &[0, 1], 2),
3627            ],
3628            loop_count: None,
3629        };
3630        expand_anytime_steps(&mut plan).unwrap();
3631        // A expands first: base_a, r_a, [b], r_a, consumer. B then expands in
3632        // place, treating A's second quantum as its gap step.
3633        assert_eq!(
3634            plan_shape(&plan),
3635            vec![
3636                (0, CuStepPhase::AnytimeBase),
3637                (0, CuStepPhase::AnytimeRefine),
3638                (1, CuStepPhase::AnytimeBase),
3639                (1, CuStepPhase::AnytimeRefine),
3640                (0, CuStepPhase::AnytimeRefine),
3641                (1, CuStepPhase::AnytimeRefine),
3642                (2, CuStepPhase::Whole),
3643            ]
3644        );
3645    }
3646
3647    #[test]
3648    fn test_anytime_expansion_requires_max_refines() {
3649        let mut plan = CuExecutionLoop {
3650            steps: vec![
3651                manual_step(anytime_node("any", None), 0, &[], 0),
3652                manual_step(Node::new("consumer", "t"), 1, &[0], 1),
3653            ],
3654            loop_count: None,
3655        };
3656        let err = expand_anytime_steps(&mut plan).unwrap_err();
3657        assert!(err.to_string().contains("needs anytime.max_refines"));
3658    }
3659}