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