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