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