Skip to main content

cu29_runtime/
curuntime.rs

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