Skip to main content

cu_python_task/
lib.rs

1//! Python-backed Copper tasks.
2//!
3//! This crate exists for rapid algorithm prototyping, not for production realtime
4//! robotics.
5//!
6//! A [`PyTask`] lets Copper keep ownership of scheduling, logging, replay, and task
7//! lifecycle while delegating one task's algorithm body to a Python function:
8//!
9//! ```python
10//! def process(ctx, input, state, output):
11//!     ...
12//! ```
13//!
14//! Two execution modes are available:
15//!
16//! - [`PyTaskMode::Process`]: spawn a separate interpreter and exchange
17//!   length-prefixed CBOR frames over stdin/stdout. This avoids putting the GIL
18//!   inside the Copper process, but adds another serialization layer, extra
19//!   copying, allocations, IPC overhead, and scheduler jitter. If a payload
20//!   contains `CuHandle<CuSharedMemoryBuffer<T>>`, the handle is exported by
21//!   descriptor so Python can read or write the underlying shared-memory bytes
22//!   without copying them through CBOR.
23//! - [`PyTaskMode::Embedded`]: call Python in-process through PyO3. This avoids
24//!   the external CBOR transport, but executes under the GIL inside the Copper
25//!   process and still allocates and converts values on every call.
26//!
27//! Both modes are fundamentally at odds with Copper's design center: predictable
28//! low-latency execution with minimal allocation on the realtime path. Using
29//! Python here will increase latency, jitter, and allocation pressure enough to
30//! ruin the realtime characteristics of the stack. Compared to a native Rust
31//! Copper task, the performance is abysmal.
32//!
33//! The intended workflow is narrow:
34//!
35//! - prototype one task quickly in Python
36//! - stabilize the algorithm
37//! - rewrite it in Rust, optionally using an LLM-assisted translation as a first
38//!   draft
39//!
40//! Do not treat Python tasks as a normal production integration path.
41
42use bincode::de::Decoder;
43use bincode::enc::Encoder;
44use bincode::error::{DecodeError, EncodeError};
45use bincode::{Decode, Encode};
46use cu29::prelude::*;
47use cu29_value::{Value as CuValue, py_to_value, to_value, value_to_py};
48use minicbor::data::{IanaTag, Int as CborInt, Type as CborType};
49use minicbor::{Decoder as CborDecoder, Encoder as CborEncoder};
50use serde::de::DeserializeOwned;
51use serde::{Deserialize, Serialize};
52use std::collections::BTreeMap;
53use std::ffi::CString;
54use std::io::{BufReader, Read, Write};
55use std::marker::PhantomData;
56use std::path::{Path, PathBuf};
57use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
58use std::sync::OnceLock;
59
60use pyo3::prelude::*;
61use pyo3::types::{PyAny, PyModule, PyTuple};
62
63const DEFAULT_SCRIPT_PATH: &str = "python/task.py";
64const MAX_CBOR_FRAME_BYTES: usize = 16 * 1024 * 1024;
65const PYTHON_BOOTSTRAP: &str = include_str!("bootstrap.py");
66const PYTHON_COMMAND_CANDIDATES: &[&str] = &["python3", "python"];
67
68/// State carried across invocations of a [`PyTask`].
69///
70/// The state must be serializable because both backends turn it into an owned,
71/// mutable Python value on every `process(...)` call and then deserialize the
72/// result back into Rust.
73pub trait PyTaskState:
74    Default
75    + Clone
76    + core::fmt::Debug
77    + Serialize
78    + DeserializeOwned
79    + Reflect
80    + TypePath
81    + GetTypeRegistration
82    + cu29::bevy_reflect::Typed
83{
84}
85
86impl<T> PyTaskState for T where
87    T: Default
88        + Clone
89        + core::fmt::Debug
90        + Serialize
91        + DeserializeOwned
92        + Reflect
93        + TypePath
94        + GetTypeRegistration
95        + cu29::bevy_reflect::Typed
96{
97}
98
99/// Describes how a Python-backed task receives its Copper inputs.
100///
101/// Python cannot safely borrow Copper messages directly, so every call converts
102/// the runtime input into an owned representation first.
103pub trait PyInputSpec {
104    type Input<'m>: CuMsgPack
105    where
106        Self: 'm;
107    type Owned: Clone + Serialize + DeserializeOwned;
108
109    fn to_owned(input: &Self::Input<'_>) -> Self::Owned;
110}
111
112/// Describes how a Python-backed task exposes Copper outputs.
113///
114/// Outputs are converted into mutable owned values before calling Python, then
115/// written back into Copper message slots after the Python function returns.
116pub trait PyOutputSpec {
117    type Output<'m>: CuMsgPayload
118    where
119        Self: 'm;
120    type Owned: Clone + Serialize + DeserializeOwned;
121
122    fn to_owned(output: &Self::Output<'_>) -> Self::Owned;
123    fn replace_output(output: &mut Self::Output<'_>, owned: Self::Owned);
124}
125
126impl PyInputSpec for () {
127    type Input<'m>
128        = ()
129    where
130        Self: 'm;
131    type Owned = ();
132
133    fn to_owned(_input: &Self::Input<'_>) -> Self::Owned {}
134}
135
136impl<T> PyInputSpec for (T,)
137where
138    T: CuMsgPayload,
139{
140    type Input<'m>
141        = input_msg!(T)
142    where
143        Self: 'm;
144    type Owned = CuMsg<T>;
145
146    fn to_owned(input: &Self::Input<'_>) -> Self::Owned {
147        input.clone()
148    }
149}
150
151macro_rules! impl_py_input_spec_tuple {
152    ($first_ty:ident => $first_var:ident, $second_ty:ident => $second_var:ident $(, $ty:ident => $var:ident)* $(,)?) => {
153        impl<$first_ty, $second_ty $(, $ty)*> PyInputSpec for ($first_ty, $second_ty $(, $ty)*)
154        where
155            $first_ty: CuMsgPayload,
156            $second_ty: CuMsgPayload,
157            $($ty: CuMsgPayload),*
158        {
159            type Input<'m>
160                = input_msg!('m, $first_ty, $second_ty $(, $ty)*)
161            where
162                Self: 'm;
163            type Owned = (CuMsg<$first_ty>, CuMsg<$second_ty> $(, CuMsg<$ty>)*);
164
165            fn to_owned(input: &Self::Input<'_>) -> Self::Owned {
166                #[allow(non_snake_case)]
167                let ($first_var, $second_var $(, $var)*) = *input;
168                ($first_var.clone(), $second_var.clone() $(, $var.clone())*)
169            }
170        }
171    };
172}
173
174impl_py_input_spec_tuple!(T1 => v1, T2 => v2);
175impl_py_input_spec_tuple!(T1 => v1, T2 => v2, T3 => v3);
176impl_py_input_spec_tuple!(T1 => v1, T2 => v2, T3 => v3, T4 => v4);
177impl_py_input_spec_tuple!(T1 => v1, T2 => v2, T3 => v3, T4 => v4, T5 => v5);
178impl_py_input_spec_tuple!(T1 => v1, T2 => v2, T3 => v3, T4 => v4, T5 => v5, T6 => v6);
179impl_py_input_spec_tuple!(
180    T1 => v1,
181    T2 => v2,
182    T3 => v3,
183    T4 => v4,
184    T5 => v5,
185    T6 => v6,
186    T7 => v7
187);
188impl_py_input_spec_tuple!(
189    T1 => v1,
190    T2 => v2,
191    T3 => v3,
192    T4 => v4,
193    T5 => v5,
194    T6 => v6,
195    T7 => v7,
196    T8 => v8
197);
198impl_py_input_spec_tuple!(
199    T1 => v1,
200    T2 => v2,
201    T3 => v3,
202    T4 => v4,
203    T5 => v5,
204    T6 => v6,
205    T7 => v7,
206    T8 => v8,
207    T9 => v9
208);
209impl_py_input_spec_tuple!(
210    T1 => v1,
211    T2 => v2,
212    T3 => v3,
213    T4 => v4,
214    T5 => v5,
215    T6 => v6,
216    T7 => v7,
217    T8 => v8,
218    T9 => v9,
219    T10 => v10
220);
221impl_py_input_spec_tuple!(
222    T1 => v1,
223    T2 => v2,
224    T3 => v3,
225    T4 => v4,
226    T5 => v5,
227    T6 => v6,
228    T7 => v7,
229    T8 => v8,
230    T9 => v9,
231    T10 => v10,
232    T11 => v11
233);
234impl_py_input_spec_tuple!(
235    T1 => v1,
236    T2 => v2,
237    T3 => v3,
238    T4 => v4,
239    T5 => v5,
240    T6 => v6,
241    T7 => v7,
242    T8 => v8,
243    T9 => v9,
244    T10 => v10,
245    T11 => v11,
246    T12 => v12
247);
248
249/// Convenience alias for the common one-input, one-output case.
250pub type PyUnaryTask<In, State, Out> = PyTask<(In,), State, (Out,)>;
251
252impl PyOutputSpec for () {
253    type Output<'m>
254        = ()
255    where
256        Self: 'm;
257    type Owned = ();
258
259    fn to_owned(_output: &Self::Output<'_>) -> Self::Owned {}
260
261    fn replace_output(_output: &mut Self::Output<'_>, _owned: Self::Owned) {}
262}
263
264impl<T> PyOutputSpec for (T,)
265where
266    T: CuMsgPayload,
267{
268    type Output<'m>
269        = output_msg!(T)
270    where
271        Self: 'm;
272    type Owned = PyCuMsg<T>;
273
274    fn to_owned(output: &Self::Output<'_>) -> Self::Owned {
275        PyCuMsg::from_output(output)
276    }
277
278    fn replace_output(output: &mut Self::Output<'_>, owned: Self::Owned) {
279        *output = owned.into_output();
280    }
281}
282
283macro_rules! impl_py_output_spec_tuple {
284    ($first_ty:ident => $first_var:ident, $second_ty:ident => $second_var:ident $(, $ty:ident => $var:ident)* $(,)?) => {
285        impl<$first_ty, $second_ty $(, $ty)*> PyOutputSpec for ($first_ty, $second_ty $(, $ty)*)
286        where
287            $first_ty: CuMsgPayload,
288            $second_ty: CuMsgPayload,
289            $($ty: CuMsgPayload),*
290        {
291            type Output<'m>
292                = output_msg!($first_ty, $second_ty $(, $ty)*)
293            where
294                Self: 'm;
295            type Owned = (PyCuMsg<$first_ty>, PyCuMsg<$second_ty> $(, PyCuMsg<$ty>)*);
296
297            fn to_owned(output: &Self::Output<'_>) -> Self::Owned {
298                #[allow(non_snake_case)]
299                let ($first_var, $second_var $(, $var)*) = output;
300                (
301                    PyCuMsg::from_output($first_var),
302                    PyCuMsg::from_output($second_var)
303                    $(, PyCuMsg::from_output($var))*
304                )
305            }
306
307            fn replace_output(output: &mut Self::Output<'_>, owned: Self::Owned) {
308                #[allow(non_snake_case)]
309                let ($first_var, $second_var $(, $var)*) = owned;
310                *output = (
311                    $first_var.into_output(),
312                    $second_var.into_output()
313                    $(, $var.into_output())*
314                );
315            }
316        }
317    };
318}
319
320impl_py_output_spec_tuple!(T1 => v1, T2 => v2);
321impl_py_output_spec_tuple!(T1 => v1, T2 => v2, T3 => v3);
322impl_py_output_spec_tuple!(T1 => v1, T2 => v2, T3 => v3, T4 => v4);
323impl_py_output_spec_tuple!(T1 => v1, T2 => v2, T3 => v3, T4 => v4, T5 => v5);
324impl_py_output_spec_tuple!(T1 => v1, T2 => v2, T3 => v3, T4 => v4, T5 => v5, T6 => v6);
325impl_py_output_spec_tuple!(
326    T1 => v1,
327    T2 => v2,
328    T3 => v3,
329    T4 => v4,
330    T5 => v5,
331    T6 => v6,
332    T7 => v7
333);
334impl_py_output_spec_tuple!(
335    T1 => v1,
336    T2 => v2,
337    T3 => v3,
338    T4 => v4,
339    T5 => v5,
340    T6 => v6,
341    T7 => v7,
342    T8 => v8
343);
344impl_py_output_spec_tuple!(
345    T1 => v1,
346    T2 => v2,
347    T3 => v3,
348    T4 => v4,
349    T5 => v5,
350    T6 => v6,
351    T7 => v7,
352    T8 => v8,
353    T9 => v9
354);
355impl_py_output_spec_tuple!(
356    T1 => v1,
357    T2 => v2,
358    T3 => v3,
359    T4 => v4,
360    T5 => v5,
361    T6 => v6,
362    T7 => v7,
363    T8 => v8,
364    T9 => v9,
365    T10 => v10
366);
367impl_py_output_spec_tuple!(
368    T1 => v1,
369    T2 => v2,
370    T3 => v3,
371    T4 => v4,
372    T5 => v5,
373    T6 => v6,
374    T7 => v7,
375    T8 => v8,
376    T9 => v9,
377    T10 => v10,
378    T11 => v11
379);
380impl_py_output_spec_tuple!(
381    T1 => v1,
382    T2 => v2,
383    T3 => v3,
384    T4 => v4,
385    T5 => v5,
386    T6 => v6,
387    T7 => v7,
388    T8 => v8,
389    T9 => v9,
390    T10 => v10,
391    T11 => v11,
392    T12 => v12
393);
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect)]
396pub enum PyTaskMode {
397    /// Run the task in a separate Python interpreter process.
398    ///
399    /// This keeps the GIL out of the Copper process, but every cycle pays for
400    /// CBOR serialization, copies, allocations, IPC, and process scheduling.
401    /// Shared-memory-backed `CuHandle` buffers are exported by descriptor
402    /// automatically on this path.
403    Process,
404    /// Run the task inside the Copper process through PyO3.
405    ///
406    /// This removes the external transport layer, but now the GIL and Python
407    /// runtime are inside the same process as Copper. This mode is unsupported
408    /// on macOS in this workspace.
409    Embedded,
410}
411
412impl PyTaskMode {
413    fn parse(value: &str) -> Option<Self> {
414        match value.trim().to_ascii_lowercase().as_str() {
415            "process" => Some(Self::Process),
416            "embedded" => Some(Self::Embedded),
417            _ => None,
418        }
419    }
420}
421
422#[derive(Serialize)]
423struct ProcessRequest<I, S, O> {
424    ctx: PyTaskContextSnapshot,
425    input: I,
426    state: S,
427    output: O,
428}
429
430impl<I, S, O> ProcessRequest<I, S, O> {
431    fn as_child_request(&self) -> ChildRequest<'_, I, S, O> {
432        ChildRequest::Process {
433            ctx: &self.ctx,
434            input: &self.input,
435            state: &self.state,
436            output: &self.output,
437        }
438    }
439}
440
441#[derive(Serialize)]
442struct StateRequest<S> {
443    ctx: PyTaskContextSnapshot,
444    state: S,
445}
446
447impl<S> StateRequest<S> {
448    fn as_child_request(&self, hook: StateHook) -> ChildRequest<'_, (), S, ()> {
449        match hook {
450            StateHook::Start => ChildRequest::Start {
451                ctx: &self.ctx,
452                state: &self.state,
453            },
454            StateHook::Stop => ChildRequest::Stop {
455                ctx: &self.ctx,
456                state: &self.state,
457            },
458        }
459    }
460}
461
462#[derive(Clone, Debug, Serialize)]
463struct PyTaskContextSnapshot {
464    now_ns: u64,
465    recent_ns: u64,
466    cl_id: u64,
467    task_id: Option<&'static str>,
468    task_index: Option<usize>,
469}
470
471impl PyTaskContextSnapshot {
472    fn from_cu_context(ctx: &CuContext) -> Self {
473        Self {
474            now_ns: ctx.now().as_nanos(),
475            recent_ns: ctx.recent().as_nanos(),
476            cl_id: ctx.cl_id(),
477            task_id: ctx.task_id(),
478            task_index: ctx.task_index(),
479        }
480    }
481}
482
483#[derive(Serialize, Deserialize)]
484struct ProcessResult<S, O> {
485    state: S,
486    output: O,
487}
488
489#[derive(Serialize, Deserialize)]
490struct StateResult<S> {
491    state: S,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize)]
495#[serde(bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned"))]
496#[doc(hidden)]
497pub struct PyCuMsg<T>
498where
499    T: CuMsgPayload,
500{
501    payload: Option<T>,
502    tov: Tov,
503    metadata: CuMsgMetadata,
504    #[serde(default, rename = "__cu_payload_present__")]
505    payload_present: bool,
506    #[serde(
507        default,
508        rename = "__cu_payload_template__",
509        skip_serializing_if = "Option::is_none"
510    )]
511    payload_template: Option<T>,
512}
513
514impl<T> PyCuMsg<T>
515where
516    T: CuMsgPayload,
517{
518    fn from_output(output: &CuMsg<T>) -> Self {
519        let payload = output.payload().cloned();
520        let payload_present = payload.is_some();
521        Self {
522            payload,
523            tov: output.tov,
524            metadata: output.metadata.clone(),
525            payload_present,
526            payload_template: (!payload_present).then(T::default),
527        }
528    }
529
530    fn into_output(self) -> CuMsg<T> {
531        let mut output = CuMsg::new(self.payload);
532        output.tov = self.tov;
533        output.metadata = self.metadata;
534        output
535    }
536}
537
538#[derive(Serialize)]
539#[serde(tag = "kind", rename_all = "snake_case")]
540enum ChildRequest<'a, I, S, O> {
541    Start {
542        ctx: &'a PyTaskContextSnapshot,
543        state: &'a S,
544    },
545    Stop {
546        ctx: &'a PyTaskContextSnapshot,
547        state: &'a S,
548    },
549    Process {
550        ctx: &'a PyTaskContextSnapshot,
551        input: &'a I,
552        state: &'a S,
553        output: &'a O,
554    },
555    Shutdown,
556}
557
558#[derive(Deserialize)]
559#[serde(tag = "kind", rename_all = "snake_case")]
560enum ChildResponse<S, O> {
561    Ready {
562        #[serde(default)]
563        cbor2_accelerated: bool,
564    },
565    State {
566        state: S,
567    },
568    Result {
569        state: S,
570        output: O,
571    },
572    Error {
573        message: String,
574    },
575}
576
577#[derive(Copy, Clone)]
578enum StateHook {
579    Start,
580    Stop,
581}
582
583/// A Copper task whose `process(...)` implementation is provided by a Python
584/// script.
585///
586/// The configured script must define:
587///
588/// ```python
589/// def process(ctx, input, state, output):
590///     ...
591/// ```
592///
593/// It may also define optional lifecycle hooks:
594///
595/// ```python
596/// def start(ctx, state):
597///     ...
598///
599/// def stop(ctx, state):
600///     ...
601/// ```
602///
603/// Missing `start`/`stop` hooks are treated as no-ops.
604///
605/// `ctx` is passed as the first argument. In embedded mode it is a live PyO3
606/// wrapper over the current Copper context; in process mode it is a per-call
607/// snapshot exposing the same clock/task metadata API.
608///
609/// Configuration keys:
610///
611/// - `script`: path to the Python file, default `python/task.py`; relative
612///   paths are resolved against the process current working directory
613/// - `mode`: `"process"` or `"embedded"`, default `"process"`
614///
615/// `input`, `state`, and `output` are all converted into owned values before the
616/// Python call. That makes the API flexible, but it also means allocations and
617/// copies happen on every call. This is the core reason the type is suitable for
618/// prototyping only and strongly discouraged on a realtime path.
619#[derive(Reflect)]
620#[reflect(no_field_bounds, from_reflect = false, type_path = false)]
621pub struct PyTask<I, S, O>
622where
623    I: PyInputSpec + 'static,
624    S: PyTaskState + 'static,
625    O: PyOutputSpec + 'static,
626{
627    mode: PyTaskMode,
628    script: String,
629    state: S,
630    #[reflect(ignore)]
631    backend: Option<PythonBackend>,
632    #[reflect(ignore)]
633    marker: PhantomData<fn() -> (I, O)>,
634}
635
636impl<I, S, O> TypePath for PyTask<I, S, O>
637where
638    I: PyInputSpec + 'static,
639    S: PyTaskState + 'static,
640    O: PyOutputSpec + 'static,
641{
642    fn type_path() -> &'static str {
643        "cu_python_task::PyTask"
644    }
645
646    fn short_type_path() -> &'static str {
647        "PyTask"
648    }
649
650    fn type_ident() -> Option<&'static str> {
651        Some("PyTask")
652    }
653
654    fn crate_name() -> Option<&'static str> {
655        Some("cu_python_task")
656    }
657
658    fn module_path() -> Option<&'static str> {
659        Some("cu_python_task")
660    }
661}
662
663impl<I, S, O> Freezable for PyTask<I, S, O>
664where
665    I: PyInputSpec + 'static,
666    S: PyTaskState + 'static,
667    O: PyOutputSpec + 'static,
668{
669    fn freeze<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
670        let bytes = minicbor_serde::to_vec(&self.state)
671            .map_err(|e| EncodeError::OtherString(e.to_string()))?;
672        Encode::encode(&bytes, encoder)
673    }
674
675    fn thaw<D: Decoder>(&mut self, decoder: &mut D) -> Result<(), DecodeError> {
676        let bytes = <Vec<u8> as Decode<D::Context>>::decode(decoder)?;
677        self.state = minicbor_serde::from_slice(&bytes)
678            .map_err(|e| DecodeError::OtherString(e.to_string()))?;
679        Ok(())
680    }
681}
682
683impl<I, S, O> CuTask for PyTask<I, S, O>
684where
685    I: PyInputSpec + 'static,
686    S: PyTaskState + 'static,
687    O: PyOutputSpec + 'static,
688{
689    type Resources<'r> = ();
690    type Input<'m> = I::Input<'m>;
691    type Output<'m> = O::Output<'m>;
692
693    fn new(config: Option<&ComponentConfig>, _resources: Self::Resources<'_>) -> CuResult<Self> {
694        let mode = parse_mode(config)?;
695        let script = resolve_script_path(config)?;
696        if !script.is_file() {
697            return Err(CuError::from(format!(
698                "Python task script '{}' does not exist",
699                script.display()
700            )));
701        }
702        Ok(Self {
703            mode,
704            script: script.display().to_string(),
705            state: S::default(),
706            backend: None,
707            marker: PhantomData,
708        })
709    }
710
711    fn start(&mut self, ctx: &CuContext) -> CuResult<()> {
712        if self.backend.is_none() {
713            let mut backend = PythonBackend::launch(self.mode, Path::new(&self.script))?;
714            backend.start_hook(ctx, &mut self.state)?;
715            self.backend = Some(backend);
716        }
717        Ok(())
718    }
719
720    fn process<'i, 'o>(
721        &mut self,
722        ctx: &CuContext,
723        input: &Self::Input<'i>,
724        output: &mut Self::Output<'o>,
725    ) -> CuResult<()> {
726        if self.backend.is_none() {
727            self.start(ctx)?;
728        }
729
730        let request = ProcessRequest {
731            ctx: PyTaskContextSnapshot::from_cu_context(ctx),
732            input: I::to_owned(input),
733            state: self.state.clone(),
734            output: O::to_owned(output),
735        };
736
737        let backend = self
738            .backend
739            .as_mut()
740            .expect("backend initialized immediately above");
741        let ProcessResult {
742            state,
743            output: new_output,
744        } = backend.process(ctx, &request)?;
745        self.state = state;
746        O::replace_output(output, new_output);
747        Ok(())
748    }
749
750    fn stop(&mut self, ctx: &CuContext) -> CuResult<()> {
751        if let Some(mut backend) = self.backend.take() {
752            backend.stop(ctx, &mut self.state)?;
753        }
754        Ok(())
755    }
756}
757
758fn parse_mode(config: Option<&ComponentConfig>) -> CuResult<PyTaskMode> {
759    let Some(config) = config else {
760        return Ok(PyTaskMode::Process);
761    };
762    let Some(raw) = config.get::<String>("mode")? else {
763        return Ok(PyTaskMode::Process);
764    };
765    PyTaskMode::parse(&raw).ok_or_else(|| {
766        CuError::from(format!(
767            "Unsupported Python task mode '{raw}', expected 'process' or 'embedded'"
768        ))
769    })
770}
771
772fn resolve_script_path(config: Option<&ComponentConfig>) -> CuResult<PathBuf> {
773    let raw = if let Some(config) = config {
774        config.get::<String>("script")?
775    } else {
776        None
777    };
778
779    let path = raw
780        .map(PathBuf::from)
781        .unwrap_or_else(|| PathBuf::from(DEFAULT_SCRIPT_PATH));
782    absolutize_path(&path)
783}
784
785fn absolutize_path(path: &Path) -> CuResult<PathBuf> {
786    if path.is_absolute() {
787        return Ok(path.to_path_buf());
788    }
789    let cwd = std::env::current_dir()
790        .map_err(|e| CuError::new_with_cause("Failed to read current working directory", e))?;
791    Ok(cwd.join(path))
792}
793
794enum PythonBackend {
795    Process(ProcessBackend),
796    Embedded(EmbeddedBackend),
797}
798
799impl PythonBackend {
800    fn launch(mode: PyTaskMode, script: &Path) -> CuResult<Self> {
801        match mode {
802            PyTaskMode::Process => Ok(Self::Process(ProcessBackend::start(script)?)),
803            PyTaskMode::Embedded => Ok(Self::Embedded(EmbeddedBackend::start(script)?)),
804        }
805    }
806
807    fn start_hook<S>(&mut self, ctx: &CuContext, state: &mut S) -> CuResult<()>
808    where
809        S: Serialize + DeserializeOwned + Clone,
810    {
811        match self {
812            Self::Process(backend) => backend.start_hook(ctx, state),
813            Self::Embedded(backend) => backend.start_hook(ctx, state),
814        }
815    }
816
817    fn process<I, S, O>(
818        &mut self,
819        ctx: &CuContext,
820        request: &ProcessRequest<I, S, O>,
821    ) -> CuResult<ProcessResult<S, O>>
822    where
823        I: Serialize,
824        S: Serialize + DeserializeOwned,
825        O: Serialize + DeserializeOwned,
826    {
827        match self {
828            Self::Process(backend) => backend.process(ctx, request),
829            Self::Embedded(backend) => backend.process(ctx, request),
830        }
831    }
832
833    fn stop<S>(&mut self, ctx: &CuContext, state: &mut S) -> CuResult<()>
834    where
835        S: Serialize + DeserializeOwned + Clone,
836    {
837        match self {
838            Self::Process(backend) => backend.stop(ctx, state),
839            Self::Embedded(backend) => backend.stop(ctx, state),
840        }
841    }
842}
843
844struct ProcessBackend {
845    child: Child,
846    stdin: ChildStdin,
847    stdout: BufReader<ChildStdout>,
848}
849
850impl ProcessBackend {
851    fn start(script: &Path) -> CuResult<Self> {
852        let python = python_command()?;
853        let mut child = Command::new(python)
854            .arg("-u")
855            .arg("-c")
856            .arg(PYTHON_BOOTSTRAP)
857            .arg(script)
858            .stdin(Stdio::piped())
859            .stdout(Stdio::piped())
860            .stderr(Stdio::inherit())
861            .spawn()
862            .map_err(|e| CuError::new_with_cause("Failed to spawn Python task process", e))?;
863
864        let stdin = child
865            .stdin
866            .take()
867            .ok_or_else(|| CuError::from("Python task process did not expose stdin"))?;
868        let stdout = child
869            .stdout
870            .take()
871            .ok_or_else(|| CuError::from("Python task process did not expose stdout"))?;
872
873        let mut backend = Self {
874            child,
875            stdin,
876            stdout: BufReader::new(stdout),
877        };
878        backend.wait_ready()?;
879        Ok(backend)
880    }
881
882    fn wait_ready(&mut self) -> CuResult<()> {
883        match read_cbor_frame::<_, ChildResponse<(), ()>>(&mut self.stdout) {
884            Ok(ChildResponse::Ready { cbor2_accelerated }) => {
885                if !cbor2_accelerated {
886                    warning!(
887                        "cu_python_task process mode is using pure-Python cbor2; install the C extension backend for better performance"
888                    );
889                }
890                Ok(())
891            }
892            Ok(ChildResponse::Error { message }) => Err(CuError::from(format!(
893                "Python task process failed during startup:\n{message}"
894            ))),
895            Ok(ChildResponse::Result { .. }) => Err(CuError::from(
896                "Unexpected result frame while waiting for Python task process startup",
897            )),
898            Ok(ChildResponse::State { .. }) => Err(CuError::from(
899                "Unexpected state-only frame while waiting for Python task process startup",
900            )),
901            Err(read_error) => {
902                if let Some(status) = self
903                    .child
904                    .try_wait()
905                    .map_err(|e| CuError::new_with_cause("Failed to poll Python task process", e))?
906                {
907                    Err(CuError::from(format!(
908                        "Python task process exited during startup with status {status}"
909                    )))
910                } else {
911                    Err(read_error)
912                }
913            }
914        }
915    }
916
917    fn process<I, S, O>(
918        &mut self,
919        _ctx: &CuContext,
920        request: &ProcessRequest<I, S, O>,
921    ) -> CuResult<ProcessResult<S, O>>
922    where
923        I: Serialize,
924        S: Serialize + DeserializeOwned,
925        O: Serialize + DeserializeOwned,
926    {
927        let child_request = request.as_child_request();
928        write_cbor_frame(&mut self.stdin, &child_request)?;
929        match read_cbor_frame::<_, ChildResponse<S, O>>(&mut self.stdout)? {
930            ChildResponse::Result { state, output } => Ok(ProcessResult { state, output }),
931            ChildResponse::State { .. } => Err(CuError::from(
932                "Python task process unexpectedly sent a state-only response while processing",
933            )),
934            ChildResponse::Error { message } => Err(CuError::from(format!(
935                "Python task process raised an exception:\n{message}"
936            ))),
937            ChildResponse::Ready { .. } => Err(CuError::from(
938                "Python task process unexpectedly sent a second ready frame",
939            )),
940        }
941    }
942
943    fn start_hook<S>(&mut self, ctx: &CuContext, state: &mut S) -> CuResult<()>
944    where
945        S: Serialize + DeserializeOwned + Clone,
946    {
947        self.call_state_hook(StateHook::Start, ctx, state)
948    }
949
950    fn call_state_hook<S>(
951        &mut self,
952        hook: StateHook,
953        ctx: &CuContext,
954        state: &mut S,
955    ) -> CuResult<()>
956    where
957        S: Serialize + DeserializeOwned + Clone,
958    {
959        let request = StateRequest {
960            ctx: PyTaskContextSnapshot::from_cu_context(ctx),
961            state: state.clone(),
962        };
963        let child_request = request.as_child_request(hook);
964        write_cbor_frame(&mut self.stdin, &child_request)?;
965        match read_cbor_frame::<_, ChildResponse<S, ()>>(&mut self.stdout)? {
966            ChildResponse::State { state: new_state } => {
967                *state = new_state;
968                Ok(())
969            }
970            ChildResponse::Result { .. } => Err(CuError::from(
971                "Python task process unexpectedly sent a process response for a state-only hook",
972            )),
973            ChildResponse::Error { message } => Err(CuError::from(format!(
974                "Python task process raised an exception:\n{message}"
975            ))),
976            ChildResponse::Ready { .. } => Err(CuError::from(
977                "Python task process unexpectedly sent a ready frame for a state-only hook",
978            )),
979        }
980    }
981
982    fn stop<S>(&mut self, ctx: &CuContext, state: &mut S) -> CuResult<()>
983    where
984        S: Serialize + DeserializeOwned + Clone,
985    {
986        self.call_state_hook(StateHook::Stop, ctx, state)?;
987        let _ = write_cbor_frame(&mut self.stdin, &ChildRequest::<(), (), ()>::Shutdown);
988        self.child
989            .wait()
990            .map_err(|e| CuError::new_with_cause("Failed to wait for Python task process", e))?;
991        Ok(())
992    }
993}
994
995#[pyclass(name = "CuContext")]
996struct PyEmbeddedContext {
997    ctx: CuContext,
998}
999
1000#[pymethods]
1001impl PyEmbeddedContext {
1002    #[getter]
1003    fn cl_id(&self) -> u64 {
1004        self.ctx.cl_id()
1005    }
1006
1007    #[getter]
1008    fn task_id(&self) -> Option<String> {
1009        self.ctx.task_id().map(str::to_string)
1010    }
1011
1012    #[getter]
1013    fn task_index(&self) -> Option<usize> {
1014        self.ctx.task_index()
1015    }
1016
1017    #[getter]
1018    fn now_ns(&self) -> u64 {
1019        self.ctx.now().as_nanos()
1020    }
1021
1022    #[getter]
1023    fn recent_ns(&self) -> u64 {
1024        self.ctx.recent().as_nanos()
1025    }
1026
1027    fn now(&self) -> u64 {
1028        self.ctx.now().as_nanos()
1029    }
1030
1031    fn recent(&self) -> u64 {
1032        self.ctx.recent().as_nanos()
1033    }
1034}
1035
1036struct EmbeddedBackend {
1037    call_process: Py<PyAny>,
1038    call_state_hook: Py<PyAny>,
1039    process_fn: Py<PyAny>,
1040    start_fn: Option<Py<PyAny>>,
1041    stop_fn: Option<Py<PyAny>>,
1042}
1043
1044impl EmbeddedBackend {
1045    fn start(script: &Path) -> CuResult<Self> {
1046        Python::initialize();
1047        Python::attach(|py| {
1048            let code = CString::new(PYTHON_BOOTSTRAP).map_err(|e| {
1049                CuError::new_with_cause("Invalid embedded Python bootstrap code", e)
1050            })?;
1051            let filename = CString::new("cu_python_task_bootstrap.py")
1052                .map_err(|e| CuError::new_with_cause("Invalid bootstrap filename", e))?;
1053            let module_name = CString::new("cu_python_task_bootstrap")
1054                .map_err(|e| CuError::new_with_cause("Invalid bootstrap module name", e))?;
1055
1056            let module = PyModule::from_code(
1057                py,
1058                code.as_c_str(),
1059                filename.as_c_str(),
1060                module_name.as_c_str(),
1061            )
1062            .map_err(python_error)?;
1063            let load_task_functions = module
1064                .getattr("load_task_functions")
1065                .map_err(python_error)?;
1066            let call_process = module.getattr("call_process").map_err(python_error)?;
1067            let call_state_hook = module
1068                .getattr("call_optional_state_hook")
1069                .map_err(python_error)?;
1070            let functions = load_task_functions
1071                .call1((script.display().to_string(),))
1072                .map_err(python_error)?;
1073            let functions = functions
1074                .cast::<PyTuple>()
1075                .map_err(|e| CuError::from(format!("Embedded Python task error: {e}")))?;
1076            let process_fn = functions.get_item(0).map_err(python_error)?;
1077            let start_fn = functions.get_item(1).map_err(python_error)?;
1078            let stop_fn = functions.get_item(2).map_err(python_error)?;
1079            Ok(Self {
1080                call_process: call_process.unbind(),
1081                call_state_hook: call_state_hook.unbind(),
1082                process_fn: process_fn.unbind(),
1083                start_fn: (!start_fn.is_none()).then(|| start_fn.unbind()),
1084                stop_fn: (!stop_fn.is_none()).then(|| stop_fn.unbind()),
1085            })
1086        })
1087    }
1088
1089    fn start_hook<S>(&self, ctx: &CuContext, state: &mut S) -> CuResult<()>
1090    where
1091        S: Serialize + DeserializeOwned + Clone,
1092    {
1093        self.call_state_hook(self.start_fn.as_ref(), ctx, state)
1094    }
1095
1096    fn process<I, S, O>(
1097        &mut self,
1098        ctx: &CuContext,
1099        request: &ProcessRequest<I, S, O>,
1100    ) -> CuResult<ProcessResult<S, O>>
1101    where
1102        I: Serialize,
1103        S: Serialize + DeserializeOwned,
1104        O: Serialize + DeserializeOwned,
1105    {
1106        let request_value = to_value(request)
1107            .map_err(|e| CuError::new_with_cause("Failed to encode embedded Python request", e))?;
1108        let response_value = Python::attach(|py| {
1109            let call_process = self.call_process.bind(py);
1110            let process_fn = self.process_fn.bind(py);
1111            let request_py = value_to_py(&request_value, py).map_err(python_error)?;
1112            let live_ctx =
1113                Py::new(py, PyEmbeddedContext { ctx: ctx.clone() }).map_err(python_error)?;
1114            call_process
1115                .call1((process_fn, request_py, live_ctx))
1116                .map_err(python_error)
1117                .and_then(|response| py_to_value(&response).map_err(python_error))
1118        })?;
1119        response_value
1120            .deserialize_into::<ProcessResult<S, O>>()
1121            .map_err(|e| CuError::new_with_cause("Failed to decode embedded Python response", e))
1122    }
1123
1124    fn call_state_hook<S>(
1125        &self,
1126        hook_fn: Option<&Py<PyAny>>,
1127        ctx: &CuContext,
1128        state: &mut S,
1129    ) -> CuResult<()>
1130    where
1131        S: Serialize + DeserializeOwned + Clone,
1132    {
1133        let request = StateRequest {
1134            ctx: PyTaskContextSnapshot::from_cu_context(ctx),
1135            state: state.clone(),
1136        };
1137        let request_value = to_value(&request).map_err(|e| {
1138            CuError::new_with_cause("Failed to encode embedded Python hook request", e)
1139        })?;
1140        let response_value = Python::attach(|py| {
1141            let call_state_hook = self.call_state_hook.bind(py);
1142            let request_py = value_to_py(&request_value, py).map_err(python_error)?;
1143            let live_ctx =
1144                Py::new(py, PyEmbeddedContext { ctx: ctx.clone() }).map_err(python_error)?;
1145            let hook_py = hook_fn.map_or_else(|| py.None(), |hook_fn| hook_fn.clone_ref(py));
1146            call_state_hook
1147                .call1((hook_py, request_py, live_ctx))
1148                .map_err(python_error)
1149                .and_then(|response| py_to_value(&response).map_err(python_error))
1150        })?;
1151        let StateResult { state: new_state } = response_value
1152            .deserialize_into::<StateResult<S>>()
1153            .map_err(|e| {
1154                CuError::new_with_cause("Failed to decode embedded Python hook response", e)
1155            })?;
1156        *state = new_state;
1157        Ok(())
1158    }
1159
1160    fn stop<S>(&self, ctx: &CuContext, state: &mut S) -> CuResult<()>
1161    where
1162        S: Serialize + DeserializeOwned + Clone,
1163    {
1164        self.call_state_hook(self.stop_fn.as_ref(), ctx, state)?;
1165        Ok(())
1166    }
1167}
1168
1169fn python_error(error: PyErr) -> CuError {
1170    CuError::from(format!("Embedded Python task error: {error}"))
1171}
1172
1173fn python_command() -> CuResult<&'static str> {
1174    static PYTHON_COMMAND: OnceLock<Option<&'static str>> = OnceLock::new();
1175    match PYTHON_COMMAND.get_or_init(detect_python_command) {
1176        Some(command) => Ok(*command),
1177        None => Err(CuError::from(
1178            "Could not find a usable Python interpreter (`python3` or `python`) in PATH",
1179        )),
1180    }
1181}
1182
1183fn detect_python_command() -> Option<&'static str> {
1184    PYTHON_COMMAND_CANDIDATES.iter().copied().find(|command| {
1185        Command::new(command)
1186            .arg("--version")
1187            .stdin(Stdio::null())
1188            .stdout(Stdio::null())
1189            .stderr(Stdio::null())
1190            .status()
1191            .map(|status| status.success())
1192            .unwrap_or(false)
1193    })
1194}
1195
1196fn write_cbor_frame<W, T>(writer: &mut W, value: &T) -> CuResult<()>
1197where
1198    W: Write,
1199    T: Serialize,
1200{
1201    let _guard = enable_shared_handle_serialization();
1202    let value = to_value(value)
1203        .map_err(|e| CuError::new_with_cause("Failed to encode Python task CBOR value", e))?;
1204    let payload = encode_wire_value(&value)?;
1205    if payload.len() > MAX_CBOR_FRAME_BYTES {
1206        return Err(CuError::from(format!(
1207            "Python task frame exceeded {} bytes",
1208            MAX_CBOR_FRAME_BYTES
1209        )));
1210    }
1211    let len = u32::try_from(payload.len())
1212        .map_err(|_| CuError::from("Python task frame length exceeded u32"))?;
1213    writer
1214        .write_all(&len.to_le_bytes())
1215        .map_err(|e| CuError::new_with_cause("Failed to write Python task frame length", e))?;
1216    writer
1217        .write_all(&payload)
1218        .map_err(|e| CuError::new_with_cause("Failed to write Python task frame payload", e))?;
1219    writer
1220        .flush()
1221        .map_err(|e| CuError::new_with_cause("Failed to flush Python task frame", e))
1222}
1223
1224fn read_cbor_frame<R, T>(reader: &mut R) -> CuResult<T>
1225where
1226    R: Read,
1227    T: DeserializeOwned,
1228{
1229    let mut len_bytes = [0_u8; 4];
1230    reader
1231        .read_exact(&mut len_bytes)
1232        .map_err(|e| CuError::new_with_cause("Failed to read Python task frame length", e))?;
1233    let len = u32::from_le_bytes(len_bytes) as usize;
1234    if len > MAX_CBOR_FRAME_BYTES {
1235        return Err(CuError::from(format!(
1236            "Python task frame exceeded {} bytes",
1237            MAX_CBOR_FRAME_BYTES
1238        )));
1239    }
1240
1241    let mut payload = vec![0_u8; len];
1242    reader
1243        .read_exact(&mut payload)
1244        .map_err(|e| CuError::new_with_cause("Failed to read Python task frame payload", e))?;
1245    let value = decode_wire_value(&payload)?;
1246    value
1247        .deserialize_into::<T>()
1248        .map_err(|e| CuError::new_with_cause("Failed to decode Python task CBOR frame", e))
1249}
1250
1251fn encode_wire_value(value: &CuValue) -> CuResult<Vec<u8>> {
1252    let mut encoder = CborEncoder::new(Vec::new());
1253    encode_wire_value_into(&mut encoder, value)
1254        .map_err(|e| CuError::new_with_cause("Failed to serialize Python task CBOR frame", e))?;
1255    Ok(encoder.into_writer())
1256}
1257
1258fn encode_wire_value_into<W>(
1259    encoder: &mut CborEncoder<W>,
1260    value: &CuValue,
1261) -> Result<(), minicbor::encode::Error<W::Error>>
1262where
1263    W: minicbor::encode::Write,
1264{
1265    match value {
1266        CuValue::Bool(v) => {
1267            encoder.bool(*v)?;
1268        }
1269        CuValue::U8(v) => {
1270            encoder.u8(*v)?;
1271        }
1272        CuValue::U16(v) => {
1273            encoder.u16(*v)?;
1274        }
1275        CuValue::U32(v) => {
1276            encoder.u32(*v)?;
1277        }
1278        CuValue::U64(v) => {
1279            encoder.u64(*v)?;
1280        }
1281        CuValue::U128(v) => {
1282            if let Ok(v) = u64::try_from(*v) {
1283                encoder.u64(v)?;
1284            } else {
1285                encode_bignum(encoder, IanaTag::PosBignum, *v)?;
1286            }
1287        }
1288        CuValue::I8(v) => {
1289            encoder.i8(*v)?;
1290        }
1291        CuValue::I16(v) => {
1292            encoder.i16(*v)?;
1293        }
1294        CuValue::I32(v) => {
1295            encoder.i32(*v)?;
1296        }
1297        CuValue::I64(v) => {
1298            encoder.i64(*v)?;
1299        }
1300        CuValue::I128(v) => {
1301            if let Ok(v) = CborInt::try_from(*v) {
1302                encoder.int(v)?;
1303            } else {
1304                encode_bignum(encoder, IanaTag::NegBignum, (-1_i128 - *v) as u128)?;
1305            }
1306        }
1307        CuValue::F32(v) => {
1308            encoder.f32(*v)?;
1309        }
1310        CuValue::F64(v) => {
1311            encoder.f64(*v)?;
1312        }
1313        CuValue::Char(v) => {
1314            encoder.char(*v)?;
1315        }
1316        CuValue::String(v) => {
1317            encoder.str(v)?;
1318        }
1319        CuValue::Unit | CuValue::Option(None) => {
1320            encoder.null()?;
1321        }
1322        CuValue::Option(Some(v)) | CuValue::Newtype(v) => {
1323            encode_wire_value_into(encoder, v)?;
1324        }
1325        CuValue::Seq(values) => {
1326            encoder.array(values.len() as u64)?;
1327            for value in values {
1328                encode_wire_value_into(encoder, value)?;
1329            }
1330        }
1331        CuValue::Map(values) => {
1332            encoder.map(values.len() as u64)?;
1333            for (key, value) in values {
1334                encode_wire_value_into(encoder, key)?;
1335                encode_wire_value_into(encoder, value)?;
1336            }
1337        }
1338        CuValue::Bytes(value) => {
1339            encoder.bytes(value)?;
1340        }
1341        CuValue::CuTime(value) => {
1342            encoder.u64(value.0)?;
1343        }
1344    }
1345    Ok(())
1346}
1347
1348fn encode_bignum<W>(
1349    encoder: &mut CborEncoder<W>,
1350    tag: IanaTag,
1351    value: u128,
1352) -> Result<(), minicbor::encode::Error<W::Error>>
1353where
1354    W: minicbor::encode::Write,
1355{
1356    let bytes = value.to_be_bytes();
1357    let start = bytes
1358        .iter()
1359        .position(|byte| *byte != 0)
1360        .unwrap_or(bytes.len() - 1);
1361    encoder.tag(tag)?.bytes(&bytes[start..])?;
1362    Ok(())
1363}
1364
1365fn decode_wire_value(payload: &[u8]) -> CuResult<CuValue> {
1366    let mut decoder = CborDecoder::new(payload);
1367    let value = decode_wire_value_from(&mut decoder)
1368        .map_err(|e| CuError::new_with_cause("Failed to decode Python task CBOR frame", e))?;
1369    if decoder.position() != payload.len() {
1370        return Err(CuError::from(
1371            "Failed to decode Python task CBOR frame: trailing data",
1372        ));
1373    }
1374    Ok(canonicalize_wire_value(value))
1375}
1376
1377fn decode_wire_value_from(
1378    decoder: &mut CborDecoder<'_>,
1379) -> Result<CuValue, minicbor::decode::Error> {
1380    match decoder.datatype()? {
1381        CborType::Bool => Ok(CuValue::Bool(decoder.bool()?)),
1382        CborType::Null => {
1383            decoder.null()?;
1384            Ok(CuValue::Option(None))
1385        }
1386        CborType::U8 => Ok(CuValue::U8(decoder.u8()?)),
1387        CborType::U16 => Ok(CuValue::U16(decoder.u16()?)),
1388        CborType::U32 => Ok(CuValue::U32(decoder.u32()?)),
1389        CborType::U64 => Ok(CuValue::U64(decoder.u64()?)),
1390        CborType::I8 => Ok(CuValue::I8(decoder.i8()?)),
1391        CborType::I16 => Ok(CuValue::I16(decoder.i16()?)),
1392        CborType::I32 => Ok(CuValue::I32(decoder.i32()?)),
1393        CborType::I64 => Ok(CuValue::I64(decoder.i64()?)),
1394        CborType::Int => decode_cbor_int(decoder.int()?),
1395        CborType::F32 => Ok(CuValue::F32(decoder.f32()?)),
1396        CborType::F64 => Ok(CuValue::F64(decoder.f64()?)),
1397        CborType::Bytes | CborType::BytesIndef => Ok(CuValue::Bytes(read_byte_string(decoder)?)),
1398        CborType::String | CborType::StringIndef => Ok(CuValue::String(read_text_string(decoder)?)),
1399        CborType::Array | CborType::ArrayIndef => decode_array(decoder),
1400        CborType::Map | CborType::MapIndef => decode_map(decoder),
1401        CborType::Tag => decode_tagged_value(decoder),
1402        other => Err(minicbor::decode::Error::message(format!(
1403            "unsupported Python task CBOR type {other}"
1404        ))),
1405    }
1406}
1407
1408fn decode_cbor_int(value: CborInt) -> Result<CuValue, minicbor::decode::Error> {
1409    if let Ok(value) = u64::try_from(value) {
1410        Ok(CuValue::U64(value))
1411    } else if let Ok(value) = i64::try_from(value) {
1412        Ok(CuValue::I64(value))
1413    } else {
1414        Ok(CuValue::I128(i128::from(value)))
1415    }
1416}
1417
1418fn decode_array(decoder: &mut CborDecoder<'_>) -> Result<CuValue, minicbor::decode::Error> {
1419    let len = decoder.array()?;
1420    let mut values = Vec::new();
1421    match len {
1422        Some(len) => {
1423            values.reserve(len as usize);
1424            for _ in 0..len {
1425                values.push(decode_wire_value_from(decoder)?);
1426            }
1427        }
1428        None => loop {
1429            if decoder.datatype()? == CborType::Break {
1430                decoder.skip()?;
1431                break;
1432            }
1433            values.push(decode_wire_value_from(decoder)?);
1434        },
1435    }
1436    Ok(CuValue::Seq(values))
1437}
1438
1439fn decode_map(decoder: &mut CborDecoder<'_>) -> Result<CuValue, minicbor::decode::Error> {
1440    let len = decoder.map()?;
1441    let mut values = BTreeMap::new();
1442    match len {
1443        Some(len) => {
1444            for _ in 0..len {
1445                let key = decode_wire_value_from(decoder)?;
1446                let value = decode_wire_value_from(decoder)?;
1447                values.insert(key, value);
1448            }
1449        }
1450        None => loop {
1451            if decoder.datatype()? == CborType::Break {
1452                decoder.skip()?;
1453                break;
1454            }
1455            let key = decode_wire_value_from(decoder)?;
1456            let value = decode_wire_value_from(decoder)?;
1457            values.insert(key, value);
1458        },
1459    }
1460    Ok(CuValue::Map(values))
1461}
1462
1463fn decode_tagged_value(decoder: &mut CborDecoder<'_>) -> Result<CuValue, minicbor::decode::Error> {
1464    match IanaTag::try_from(decoder.tag()?) {
1465        Ok(IanaTag::PosBignum) => {
1466            let value = decode_bignum(decoder)?;
1467            if let Ok(value) = u64::try_from(value) {
1468                Ok(CuValue::U64(value))
1469            } else {
1470                Ok(CuValue::U128(value))
1471            }
1472        }
1473        Ok(IanaTag::NegBignum) => {
1474            let value = decode_bignum(decoder)?;
1475            if value <= i64::MAX as u128 {
1476                Ok(CuValue::I64(-1 - value as i64))
1477            } else if let Ok(value) = i128::try_from(value) {
1478                Ok(CuValue::I128(-1 - value))
1479            } else {
1480                Err(minicbor::decode::Error::message(
1481                    "Python task CBOR negative bignum exceeded i128",
1482                ))
1483            }
1484        }
1485        Ok(other) => Err(minicbor::decode::Error::message(format!(
1486            "unsupported Python task CBOR tag {}",
1487            u64::from(other)
1488        ))),
1489        Err(tag) => Err(minicbor::decode::Error::message(format!(
1490            "unsupported Python task CBOR tag {tag}",
1491        ))),
1492    }
1493}
1494
1495fn decode_bignum(decoder: &mut CborDecoder<'_>) -> Result<u128, minicbor::decode::Error> {
1496    let bytes = read_byte_string(decoder)?;
1497    let first = bytes
1498        .iter()
1499        .position(|byte| *byte != 0)
1500        .unwrap_or(bytes.len());
1501    let bytes = &bytes[first..];
1502    if bytes.len() > 16 {
1503        return Err(minicbor::decode::Error::message(
1504            "Python task CBOR bignum exceeded u128",
1505        ));
1506    }
1507    let mut buffer = [0_u8; 16];
1508    buffer[16 - bytes.len()..].copy_from_slice(bytes);
1509    Ok(u128::from_be_bytes(buffer))
1510}
1511
1512fn read_byte_string(decoder: &mut CborDecoder<'_>) -> Result<Vec<u8>, minicbor::decode::Error> {
1513    match decoder.datatype()? {
1514        CborType::Bytes => Ok(decoder.bytes()?.to_vec()),
1515        CborType::BytesIndef => {
1516            let mut bytes = Vec::new();
1517            for chunk in decoder.bytes_iter()? {
1518                bytes.extend_from_slice(chunk?);
1519            }
1520            Ok(bytes)
1521        }
1522        other => Err(minicbor::decode::Error::message(format!(
1523            "expected byte string, found {other}"
1524        ))),
1525    }
1526}
1527
1528fn read_text_string(decoder: &mut CborDecoder<'_>) -> Result<String, minicbor::decode::Error> {
1529    match decoder.datatype()? {
1530        CborType::String => Ok(decoder.str()?.to_owned()),
1531        CborType::StringIndef => {
1532            let mut text = String::new();
1533            for chunk in decoder.str_iter()? {
1534                text.push_str(chunk?);
1535            }
1536            Ok(text)
1537        }
1538        other => Err(minicbor::decode::Error::message(format!(
1539            "expected text string, found {other}"
1540        ))),
1541    }
1542}
1543
1544fn canonicalize_wire_value(value: CuValue) -> CuValue {
1545    match value {
1546        CuValue::Option(None) => CuValue::Unit,
1547        CuValue::Option(Some(value)) | CuValue::Newtype(value) => canonicalize_wire_value(*value),
1548        CuValue::Seq(values) => CuValue::Seq(
1549            values
1550                .into_iter()
1551                .map(canonicalize_wire_value)
1552                .collect::<Vec<_>>(),
1553        ),
1554        CuValue::Map(values) => CuValue::Map(
1555            values
1556                .into_iter()
1557                .map(|(key, value)| (canonicalize_wire_value(key), canonicalize_wire_value(value)))
1558                .collect::<BTreeMap<_, _>>(),
1559        ),
1560        other => other,
1561    }
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566    use super::*;
1567    use cu29::prelude::{CuContext, RobotClock};
1568    use std::sync::{Mutex, MutexGuard, OnceLock};
1569    use tempfile::TempDir;
1570
1571    #[derive(
1572        Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode, Reflect,
1573    )]
1574    struct TestPayload {
1575        value: i32,
1576        flag: bool,
1577    }
1578
1579    #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1580    struct SeenState {
1581        seen: bool,
1582    }
1583
1584    #[derive(Debug, Clone, Serialize, Deserialize)]
1585    struct SharedHandleInput {
1586        data: CuHandle<CuSharedMemoryBuffer<u8>>,
1587    }
1588
1589    const TEST_TASK_IDS: &[&str] = &["py"];
1590
1591    fn cwd_lock() -> MutexGuard<'static, ()> {
1592        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1593        LOCK.get_or_init(|| Mutex::new(()))
1594            .lock()
1595            .expect("cwd lock poisoned")
1596    }
1597
1598    struct CurrentDirGuard {
1599        original: PathBuf,
1600    }
1601
1602    impl CurrentDirGuard {
1603        fn switch_to(path: &Path) -> Self {
1604            let original = std::env::current_dir().expect("cwd");
1605            std::env::set_current_dir(path).expect("switch cwd");
1606            Self { original }
1607        }
1608    }
1609
1610    impl Drop for CurrentDirGuard {
1611        fn drop(&mut self) {
1612            std::env::set_current_dir(&self.original).expect("restore cwd");
1613        }
1614    }
1615
1616    #[test]
1617    fn mode_defaults_to_process() {
1618        assert_eq!(parse_mode(None).unwrap(), PyTaskMode::Process);
1619    }
1620
1621    #[test]
1622    fn mode_parses_embedded() {
1623        let mut cfg = ComponentConfig::new();
1624        cfg.set("mode", "embedded".to_string());
1625        assert_eq!(parse_mode(Some(&cfg)).unwrap(), PyTaskMode::Embedded);
1626    }
1627
1628    #[test]
1629    fn invalid_mode_is_rejected() {
1630        let mut cfg = ComponentConfig::new();
1631        cfg.set("mode", "nope".to_string());
1632        let err = parse_mode(Some(&cfg)).expect_err("mode should fail");
1633        assert!(err.to_string().contains("Unsupported Python task mode"));
1634    }
1635
1636    #[test]
1637    fn default_script_path_uses_python_task_py_from_cwd() {
1638        let _lock = cwd_lock();
1639        let temp_dir = TempDir::new().expect("temp dir");
1640        let _cwd = CurrentDirGuard::switch_to(temp_dir.path());
1641        let cwd = std::env::current_dir().expect("cwd after switch");
1642        let resolved = resolve_script_path(None).expect("resolve");
1643        assert_eq!(resolved, cwd.join(DEFAULT_SCRIPT_PATH));
1644    }
1645
1646    #[test]
1647    fn relative_script_paths_are_absolutized() {
1648        let _lock = cwd_lock();
1649        let temp_dir = TempDir::new().expect("temp dir");
1650        let _cwd = CurrentDirGuard::switch_to(temp_dir.path());
1651        let cwd = std::env::current_dir().expect("cwd after switch");
1652
1653        let mut cfg = ComponentConfig::new();
1654        cfg.set("script", "scripts/example.py".to_string());
1655        let resolved = resolve_script_path(Some(&cfg)).expect("resolve");
1656
1657        assert_eq!(resolved, cwd.join("scripts/example.py"));
1658    }
1659
1660    fn write_test_script(contents: &str) -> (TempDir, PathBuf) {
1661        let temp_dir = TempDir::new().expect("temp dir");
1662        let script = temp_dir.path().join("task.py");
1663        std::fs::write(&script, contents).expect("write script");
1664        (temp_dir, script)
1665    }
1666
1667    fn test_context(now_ns: u64, cl_id: u64) -> CuContext {
1668        let (clock, mock) = RobotClock::mock();
1669        mock.set_value(now_ns);
1670        let mut ctx = CuContext::builder(clock)
1671            .cl_id(cl_id)
1672            .task_ids(TEST_TASK_IDS)
1673            .build();
1674        ctx.set_current_task(0);
1675        ctx
1676    }
1677
1678    fn test_context_snapshot(now_ns: u64, cl_id: u64) -> PyTaskContextSnapshot {
1679        PyTaskContextSnapshot {
1680            now_ns,
1681            recent_ns: now_ns,
1682            cl_id,
1683            task_id: Some("py"),
1684            task_index: Some(0),
1685        }
1686    }
1687
1688    fn stop_process_backend<S>(backend: &mut ProcessBackend, ctx: &CuContext, state: &mut S)
1689    where
1690        S: Serialize + DeserializeOwned + Clone,
1691    {
1692        backend.stop(ctx, state).expect("stop backend");
1693    }
1694
1695    fn stop_embedded_backend<S>(backend: &mut EmbeddedBackend, ctx: &CuContext, state: &mut S)
1696    where
1697        S: Serialize + DeserializeOwned + Clone,
1698    {
1699        backend.stop(ctx, state).expect("stop backend");
1700    }
1701
1702    #[test]
1703    fn process_backend_keeps_absent_output_payload_when_python_does_not_touch_it() {
1704        let ctx = test_context(1, 7);
1705        let (_temp_dir, script) =
1706            write_test_script("def process(ctx, inp, state, output):\n    state['seen'] = True\n");
1707        let mut backend = ProcessBackend::start(&script).expect("start backend");
1708        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1709
1710        let mut result: ProcessResult<SeenState, PyCuMsg<TestPayload>> = backend
1711            .process(
1712                &ctx,
1713                &ProcessRequest {
1714                    ctx: PyTaskContextSnapshot::from_cu_context(&ctx),
1715                    input: (),
1716                    state: SeenState::default(),
1717                    output,
1718                },
1719            )
1720            .expect("process request");
1721        stop_process_backend(&mut backend, &ctx, &mut result.state);
1722
1723        assert!(result.state.seen);
1724        assert!(result.output.payload.is_none());
1725    }
1726
1727    #[test]
1728    fn process_backend_supports_attribute_writes_on_absent_output_payload() {
1729        let ctx = test_context(41, 7);
1730        let (_temp_dir, script) = write_test_script(
1731            "def process(ctx, inp, state, output):\n    output.payload.value = 41\n    output.payload.flag = True\n",
1732        );
1733        let mut backend = ProcessBackend::start(&script).expect("start backend");
1734        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1735
1736        let mut result: ProcessResult<(), PyCuMsg<TestPayload>> = backend
1737            .process(
1738                &ctx,
1739                &ProcessRequest {
1740                    ctx: PyTaskContextSnapshot::from_cu_context(&ctx),
1741                    input: (),
1742                    state: (),
1743                    output,
1744                },
1745            )
1746            .expect("process request");
1747        stop_process_backend(&mut backend, &ctx, &mut result.state);
1748
1749        assert_eq!(
1750            result.output.payload,
1751            Some(TestPayload {
1752                value: 41,
1753                flag: true,
1754            })
1755        );
1756    }
1757
1758    #[test]
1759    fn process_backend_preserves_rebound_scalar_state() {
1760        let ctx = test_context(41, 7);
1761        let (_temp_dir, script) = write_test_script(
1762            "def process(ctx, inp, current_state, out):\n    current_state = current_state + 1\n",
1763        );
1764        let mut backend = ProcessBackend::start(&script).expect("start backend");
1765        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1766
1767        let mut result: ProcessResult<u64, PyCuMsg<TestPayload>> = backend
1768            .process(
1769                &ctx,
1770                &ProcessRequest {
1771                    ctx: PyTaskContextSnapshot::from_cu_context(&ctx),
1772                    input: (),
1773                    state: 41_u64,
1774                    output,
1775                },
1776            )
1777            .expect("process request");
1778        stop_process_backend(&mut backend, &ctx, &mut result.state);
1779
1780        assert_eq!(result.state, 42);
1781        assert!(result.output.payload.is_none());
1782    }
1783
1784    #[test]
1785    fn process_backend_receives_ctx_snapshot() {
1786        let rust_ctx = test_context(1, 2);
1787        let request_ctx = test_context_snapshot(41, 7);
1788        let (_temp_dir, script) = write_test_script(
1789            "def process(ctx, inp, state, output):\n    output.payload.value = ctx.now()\n    output.payload.flag = (ctx.cl_id == 7 and ctx.task_id == 'py' and ctx.task_index == 0)\n",
1790        );
1791        let mut backend = ProcessBackend::start(&script).expect("start backend");
1792        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1793
1794        let mut result: ProcessResult<(), PyCuMsg<TestPayload>> = backend
1795            .process(
1796                &rust_ctx,
1797                &ProcessRequest {
1798                    ctx: request_ctx,
1799                    input: (),
1800                    state: (),
1801                    output,
1802                },
1803            )
1804            .expect("process request");
1805        stop_process_backend(&mut backend, &rust_ctx, &mut result.state);
1806
1807        assert_eq!(
1808            result.output.payload,
1809            Some(TestPayload {
1810                value: 41,
1811                flag: true,
1812            })
1813        );
1814    }
1815
1816    #[test]
1817    fn process_backend_optional_start_and_stop_hooks_are_noops() {
1818        let ctx = test_context(41, 7);
1819        let (_temp_dir, script) =
1820            write_test_script("def process(ctx, inp, state, output):\n    pass\n");
1821        let mut backend = ProcessBackend::start(&script).expect("start backend");
1822        let mut state = 11_u64;
1823
1824        backend.start_hook(&ctx, &mut state).expect("start hook");
1825        assert_eq!(state, 11);
1826
1827        stop_process_backend(&mut backend, &ctx, &mut state);
1828        assert_eq!(state, 11);
1829    }
1830
1831    #[test]
1832    fn process_backend_runs_optional_start_and_stop_hooks() {
1833        let start_ctx = test_context(40, 2);
1834        let stop_ctx = test_context(1, 5);
1835        let (_temp_dir, script) = write_test_script(
1836            "def start(ctx, state):\n    state = ctx.now() + ctx.cl_id\n\n\
1837def process(ctx, inp, state, output):\n    pass\n\n\
1838def stop(ctx, state):\n    state = state + ctx.cl_id\n",
1839        );
1840        let mut backend = ProcessBackend::start(&script).expect("start backend");
1841        let mut state = 0_u64;
1842
1843        backend
1844            .start_hook(&start_ctx, &mut state)
1845            .expect("start hook");
1846        assert_eq!(state, 42);
1847
1848        stop_process_backend(&mut backend, &stop_ctx, &mut state);
1849        assert_eq!(state, 47);
1850    }
1851
1852    #[test]
1853    fn process_backend_shared_handles_are_exposed_without_copying() {
1854        let ctx = test_context(41, 7);
1855        let (_temp_dir, script) = write_test_script(
1856            "def process(ctx, inp, state, output):\n\
1857             \x20\x20\x20\x20view = inp.data.memoryview()\n\
1858             \x20\x20\x20\x20output.payload.value = view[0] + view[1] + view[2] + view[3]\n\
1859             \x20\x20\x20\x20output.payload.flag = True\n\
1860             \x20\x20\x20\x20view[0] = 9\n",
1861        );
1862        let pool = CuSharedMemoryPool::<u8>::new("py_shm_test", 1, 4).expect("shared pool");
1863        let handle = pool.acquire().expect("pooled handle");
1864        handle.with_inner_mut(|inner| inner.copy_from_slice(&[1, 2, 3, 4]));
1865
1866        let mut backend = ProcessBackend::start(&script).expect("start backend");
1867        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1868        let mut result: ProcessResult<(), PyCuMsg<TestPayload>> = backend
1869            .process(
1870                &ctx,
1871                &ProcessRequest {
1872                    ctx: PyTaskContextSnapshot::from_cu_context(&ctx),
1873                    input: SharedHandleInput {
1874                        data: handle.clone(),
1875                    },
1876                    state: (),
1877                    output,
1878                },
1879            )
1880            .expect("process request");
1881        stop_process_backend(&mut backend, &ctx, &mut result.state);
1882
1883        assert_eq!(
1884            result.output.payload,
1885            Some(TestPayload {
1886                value: 10,
1887                flag: true,
1888            })
1889        );
1890        let observed = handle.with_inner(|inner| inner[0]);
1891        assert_eq!(observed, 9);
1892    }
1893
1894    #[test]
1895    fn cbor_wire_frames_round_trip_128_bit_integers() {
1896        let mut buffer = Vec::new();
1897        let large_u128 = u128::from(u64::MAX) + 99;
1898        let large_i128 = i128::from(i64::MIN) - 99;
1899
1900        write_cbor_frame(&mut buffer, &large_u128).expect("serialize u128");
1901        let decoded_u128 = read_cbor_frame::<_, u128>(&mut std::io::Cursor::new(&buffer))
1902            .expect("deserialize u128");
1903        assert_eq!(decoded_u128, large_u128);
1904
1905        buffer.clear();
1906
1907        write_cbor_frame(&mut buffer, &large_i128).expect("serialize i128");
1908        let decoded_i128 = read_cbor_frame::<_, i128>(&mut std::io::Cursor::new(&buffer))
1909            .expect("deserialize i128");
1910        assert_eq!(decoded_i128, large_i128);
1911    }
1912
1913    #[test]
1914    fn embedded_backend_supports_attribute_writes_on_absent_output_payload() {
1915        let ctx = test_context(41, 7);
1916        let (_temp_dir, script) = write_test_script(
1917            "def process(ctx, inp, state, output):\n    output.payload.value = 41\n    output.payload.flag = True\n",
1918        );
1919        let mut backend = EmbeddedBackend::start(&script).expect("start backend");
1920        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1921
1922        let mut result: ProcessResult<(), PyCuMsg<TestPayload>> = backend
1923            .process(
1924                &ctx,
1925                &ProcessRequest {
1926                    ctx: PyTaskContextSnapshot::from_cu_context(&ctx),
1927                    input: (),
1928                    state: (),
1929                    output,
1930                },
1931            )
1932            .expect("process request");
1933        stop_embedded_backend(&mut backend, &ctx, &mut result.state);
1934
1935        assert_eq!(
1936            result.output.payload,
1937            Some(TestPayload {
1938                value: 41,
1939                flag: true,
1940            })
1941        );
1942    }
1943
1944    #[test]
1945    fn embedded_backend_preserves_rebound_scalar_state() {
1946        let ctx = test_context(41, 7);
1947        let (_temp_dir, script) = write_test_script(
1948            "def process(ctx, inp, current_state, out):\n    current_state = current_state + 1\n",
1949        );
1950        let mut backend = EmbeddedBackend::start(&script).expect("start backend");
1951        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1952
1953        let mut result: ProcessResult<u64, PyCuMsg<TestPayload>> = backend
1954            .process(
1955                &ctx,
1956                &ProcessRequest {
1957                    ctx: PyTaskContextSnapshot::from_cu_context(&ctx),
1958                    input: (),
1959                    state: 41_u64,
1960                    output,
1961                },
1962            )
1963            .expect("process request");
1964        stop_embedded_backend(&mut backend, &ctx, &mut result.state);
1965
1966        assert_eq!(result.state, 42);
1967        assert!(result.output.payload.is_none());
1968    }
1969
1970    #[test]
1971    fn embedded_backend_receives_live_ctx() {
1972        let ctx = test_context(41, 7);
1973        let request_ctx = test_context_snapshot(1, 2);
1974        let (_temp_dir, script) = write_test_script(
1975            "def process(ctx, inp, state, output):\n    output.payload.value = ctx.now()\n    output.payload.flag = (ctx.cl_id == 7 and ctx.task_id == 'py' and ctx.task_index == 0)\n",
1976        );
1977        let mut backend = EmbeddedBackend::start(&script).expect("start backend");
1978        let output = PyCuMsg::from_output(&CuMsg::<TestPayload>::default());
1979
1980        let mut result: ProcessResult<(), PyCuMsg<TestPayload>> = backend
1981            .process(
1982                &ctx,
1983                &ProcessRequest {
1984                    ctx: request_ctx,
1985                    input: (),
1986                    state: (),
1987                    output,
1988                },
1989            )
1990            .expect("process request");
1991        stop_embedded_backend(&mut backend, &ctx, &mut result.state);
1992
1993        assert_eq!(
1994            result.output.payload,
1995            Some(TestPayload {
1996                value: 41,
1997                flag: true,
1998            })
1999        );
2000    }
2001
2002    #[test]
2003    fn embedded_backend_optional_start_and_stop_hooks_are_noops() {
2004        let ctx = test_context(41, 7);
2005        let (_temp_dir, script) =
2006            write_test_script("def process(ctx, inp, state, output):\n    pass\n");
2007        let mut backend = EmbeddedBackend::start(&script).expect("start backend");
2008        let mut state = 11_u64;
2009
2010        backend.start_hook(&ctx, &mut state).expect("start hook");
2011        assert_eq!(state, 11);
2012
2013        stop_embedded_backend(&mut backend, &ctx, &mut state);
2014        assert_eq!(state, 11);
2015    }
2016
2017    #[test]
2018    fn embedded_backend_runs_optional_start_and_stop_hooks() {
2019        let start_ctx = test_context(40, 2);
2020        let stop_ctx = test_context(1, 5);
2021        let (_temp_dir, script) = write_test_script(
2022            "def start(ctx, state):\n    state = ctx.now() + ctx.cl_id\n\n\
2023def process(ctx, inp, state, output):\n    pass\n\n\
2024def stop(ctx, state):\n    state = state + ctx.cl_id\n",
2025        );
2026        let mut backend = EmbeddedBackend::start(&script).expect("start backend");
2027        let mut state = 0_u64;
2028
2029        backend
2030            .start_hook(&start_ctx, &mut state)
2031            .expect("start hook");
2032        assert_eq!(state, 42);
2033
2034        stop_embedded_backend(&mut backend, &stop_ctx, &mut state);
2035        assert_eq!(state, 47);
2036    }
2037}