Skip to main content

cu29_runtime/
curuntime.rs

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