1use 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
68pub 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
99pub 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
112pub 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
249pub 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 Process,
404 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#[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}