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