diff --git a/Cargo.lock b/Cargo.lock index 2dae8ffeb..5e6f54ecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,13 +127,20 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -538,6 +545,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "huntsman-complex" +version = "0.1.0" +dependencies = [ + "huntsman-complex-types", + "serde", + "spider-tdl", +] + +[[package]] +name = "huntsman-complex-types" +version = "0.1.0" +dependencies = [ + "serde", + "spider-tdl", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -675,6 +699,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "lazy_static" version = "1.5.0" @@ -696,6 +735,16 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1362,11 +1411,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "spider-task-executor" +version = "0.1.0" +dependencies = [ + "anyhow", + "libloading", + "rmp-serde", + "spider-tdl", + "thiserror", +] + [[package]] name = "spider-tdl" version = "0.1.0" dependencies = [ "anyhow", + "const-str", "rmp-serde", "serde", "spider-core", @@ -1695,6 +1756,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tdl-integration" +version = "0.1.0" +dependencies = [ + "anyhow", + "huntsman-complex-types", + "rmp-serde", + "spider-core", + "spider-task-executor", + "spider-tdl", +] + [[package]] name = "testing_table" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 74fc3630d..307961435 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ members = [ "components/spider-core", "components/spider-derive", "components/spider-storage", + "components/spider-task-executor", "components/spider-tdl", "components/spider-tdl-derive", + "examples/huntsman/complex/tasks", + "examples/huntsman/complex/types", + "tests/huntsman/tdl-integration", ] diff --git a/components/spider-task-executor/Cargo.toml b/components/spider-task-executor/Cargo.toml new file mode 100644 index 000000000..c51c09b29 --- /dev/null +++ b/components/spider-task-executor/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "spider-task-executor" +version = "0.1.0" +edition = "2024" + +[lib] +name = "spider_task_executor" +path = "src/lib.rs" + +[dependencies] +libloading = "0.8.5" +rmp-serde = "1.3.1" +spider-tdl = { path = "../spider-tdl" } +thiserror = "2.0.18" + +[dev-dependencies] +anyhow = "1.0.98" diff --git a/components/spider-task-executor/src/error.rs b/components/spider-task-executor/src/error.rs new file mode 100644 index 000000000..da582342c --- /dev/null +++ b/components/spider-task-executor/src/error.rs @@ -0,0 +1,66 @@ +//! Errors produced while loading TDL packages or executing tasks across the FFI boundary. + +use spider_tdl::{TdlError, Version}; + +/// All possible errors produced by the task executor. +/// +/// [`TdlError`] (failure inside a user task) is wrapped via [`Self::TaskError`] so callers can +/// distinguish executor-internal failures from in-task failures. +#[derive(Debug, thiserror::Error)] +pub enum ExecutorError { + /// `dlopen` failed or a required FFI symbol was missing. + #[error("failed to load TDL package library: {0}")] + InvalidLibrary(#[from] libloading::Error), + + /// The package's declared `spider-tdl` ABI version is not compatible with the executor's. + #[error( + "incompatible spider-tdl version: \ + package={package_major}.{package_minor}.{package_patch}, \ + executor={executor_major}.{executor_minor}.{executor_patch}" + )] + IncompatibleVersion { + package_major: u32, + package_minor: u32, + package_patch: u32, + executor_major: u32, + executor_minor: u32, + executor_patch: u32, + }, + + /// Two packages with the same `package_name` were registered with the same manager. + #[error("duplicate package name: {0}")] + DuplicatePackage(String), + + /// The byte buffer contains invalid UTF-8 patterns. + #[error("invalid UTF-8: {0}")] + InvalidUtf8(#[from] std::str::Utf8Error), + + /// A user task returned a [`TdlError`] across the FFI boundary. + #[error("task execution failed: {0}")] + TaskError(#[from] TdlError), + + /// The msgpack-encoded error payload returned by a failing task could not be decoded back into + /// a [`TdlError`]. + #[error("failed to deserialize error payload: {0}")] + ErrorPayloadDeserializationFailure(#[from] rmp_serde::decode::Error), +} + +impl ExecutorError { + /// Constructs an [`ExecutorError::IncompatibleVersion`] from the package and executor + /// [`Version`] values. + /// + /// # Returns + /// + /// The constructed error variant. + #[must_use] + pub const fn incompatible_version(package: Version, executor: Version) -> Self { + Self::IncompatibleVersion { + package_major: package.major, + package_minor: package.minor, + package_patch: package.patch, + executor_major: executor.major, + executor_minor: executor.minor, + executor_patch: executor.patch, + } + } +} diff --git a/components/spider-task-executor/src/lib.rs b/components/spider-task-executor/src/lib.rs new file mode 100644 index 000000000..b5b05076b --- /dev/null +++ b/components/spider-task-executor/src/lib.rs @@ -0,0 +1,7 @@ +//! Spider task executor for executing tasks from TDL packages. + +pub mod error; +pub mod manager; + +pub use error::ExecutorError; +pub use manager::{TdlPackage, TdlPackageManager}; diff --git a/components/spider-task-executor/src/manager.rs b/components/spider-task-executor/src/manager.rs new file mode 100644 index 000000000..49fca52b7 --- /dev/null +++ b/components/spider-task-executor/src/manager.rs @@ -0,0 +1,262 @@ +//! Loads and indexes TDL packages compiled as cdylibs. +//! +//! See [`TdlPackage`] for the per-library wrapper and [`TdlPackageManager`] for the top-level +//! collection that enforces unique package names. + +use std::{collections::HashMap, path::Path}; + +use libloading::{Library, Symbol}; +use spider_tdl::{ + TdlError, + Version, + ffi::{CByteArray, CCharArray, TaskExecutionResult}, +}; + +use crate::error::ExecutorError; + +/// A single dlopen'd TDL package. +/// +/// Owns the [`Library`] handle for the lifetime of the value; the dylib stays mapped until the +/// `TdlPackage` is dropped. The package's name and version are queried at load time and cached to +/// avoid repeating the FFI round trip on every call. The execute fn pointer is also resolved once +/// at load time and cached so each [`Self::execute_task`] call doesn't require `dlsym` per +/// dispatch. +pub struct TdlPackage { + /// The name of the package. + name: String, + + /// The TDL version used to generate the package. + version: Version, + + /// Cached fn pointer for `__spider_tdl_package_execute`, resolved once at load time. Valid + /// for the lifetime of `library` (i.e., for the lifetime of `Self`). + execute: ExecuteFn, + + /// Holds the dylib mapped in memory. Never read directly after construction, but its `Drop` + /// impl unmaps the library, which would invalidate `execute`. `library` must outlive + /// `execute`; field-declaration order ensures `execute` is dropped first. + _library: Library, +} + +impl TdlPackage { + /// Loads a TDL package from a filesystem path and verifies its `spider-tdl` ABI version. + /// + /// The load sequence runs in the following order. Failure at any step aborts the load and drops + /// the library before returning, leaving the caller with no resources to clean up: + /// + /// 1. `dlopen` the library at `path`. + /// 2. Look up [`SYM_GET_VERSION`], call it, and verify the returned [`Version`] is compatible + /// with [`Version::SPIDER_TDL`]. + /// 3. Look up [`SYM_GET_NAME`], call it, and decode the returned bytes as UTF-8. + /// 4. Look up [`SYM_EXECUTE`] and cache the fn pointer for per-task dispatch. + /// + /// # Returns + /// + /// The loaded package on success, with its name, version, and execute fn pointer cached. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ExecutorError::IncompatibleVersion`] if the package was built against an incompatible + /// `spider-tdl` release. + /// * Forwards [`Library::new`]'s return values on failure. + /// * Forwards [`Library::get`]'s return values on failure for loading [`SYM_GET_VERSION`], + /// [`SYM_GET_NAME`], or [`SYM_EXECUTE`]. + /// * Forwards [`CCharArray::as_utf8`]'s return values on failure. + pub fn load(path: &Path) -> Result { + // SAFETY: `Library::new` runs the dylib's initializers. Spider's design treats every TDL + // package as trusted code installed by the operator, so this is the unsafety boundary for + // the whole executor. + let library = unsafe { Library::new(path) }?; + + // SAFETY: the symbol is read with the exact `extern "C"` signature the registration macro + // emits. A mismatch is impossible if the package was built with `spider-tdl`'s + // `register_tdl_package!`. If the symbol is missing or the dylib is not a TDL package, the + // `library.get` call returns an error. + let version = unsafe { + let get_version: Symbol = library.get(SYM_GET_VERSION)?; + get_version() + }; + + let executor_version = Version::SPIDER_TDL; + if !executor_version.is_compatible_with(&version) { + return Err(ExecutorError::incompatible_version( + version, + executor_version, + )); + } + + // SAFETY: see the SAFETY comment on the version lookup above. + let name_array = unsafe { + let get_name: Symbol = library.get(SYM_GET_NAME)?; + get_name() + }; + let name = name_array.as_utf8()?.to_owned(); + + // SAFETY: see the SAFETY comment on the version lookup above. We deref the borrowed + // `Symbol` to copy out the underlying fn pointer (`ExecuteFn` is `Copy`); the + // pointer remains valid for as long as `library` stays loaded, which is the entire + // lifetime of `Self`. + let execute = unsafe { + let symbol: Symbol = library.get(SYM_EXECUTE)?; + *symbol + }; + + Ok(Self { + name, + version, + execute, + _library: library, + }) + } + + /// # Returns + /// + /// The package's declared name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// # Returns + /// + /// The `spider-tdl` ABI [`Version`] the package was compiled against. + #[must_use] + pub const fn version(&self) -> Version { + self.version + } + + /// Dispatches a task by name and returns the wire-format-encoded output buffer. + /// + /// `raw_ctx` is a msgpack-encoded + /// [`TaskContext`](spider_tdl::TaskContext); `raw_inputs` is a wire-format-encoded + /// [`TaskInputsSerializer`](spider_tdl::wire::TaskInputsSerializer) buffer. + /// + /// # Returns + /// + /// The wire-format-encoded outputs on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ExecutorError::TaskError`] if the user task returned a [`TdlError`] (including + /// `TaskNotFound` when `task_name` is unknown to the package). + /// * Forwards [`rmp_serde::from_slice`]'s return values on failure. + pub fn execute_task( + &self, + task_name: &str, + raw_ctx: &[u8], + raw_inputs: &[u8], + ) -> Result, ExecutorError> { + let name_view = CCharArray::from_utf8(task_name); + let ctx_view = CByteArray::from_slice(raw_ctx); + let inputs_view = CByteArray::from_slice(raw_inputs); + + // SAFETY: `self.execute` was extracted at load time from a `Symbol` resolved + // against `self.library`. The library is still mapped (we own it) and the package protocol + // is fixed by the version handshake at load time, so the call signature matches. + let result = unsafe { (self.execute)(name_view, ctx_view, inputs_view) }; + + match result.into_result() { + Ok(output_bytes) => Ok(output_bytes), + Err(error_bytes) => { + let err: TdlError = rmp_serde::from_slice(&error_bytes)?; + Err(ExecutorError::TaskError(err)) + } + } + } +} + +/// Indexes loaded [`TdlPackage`]s by their declared name and rejects duplicates. +#[derive(Default)] +pub struct TdlPackageManager { + packages: HashMap, +} + +impl TdlPackageManager { + /// Factory function. + /// + /// # Returns + /// + /// An empty package manager. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Loads the package at `path` and indexes it by its declared name. + /// + /// # Returns + /// + /// The newly loaded package's name on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`ExecutorError::DuplicatePackage`] if a package with the same name is already loaded. The + /// freshly loaded library will be dropped (unloaded). + /// * Forwards [`TdlPackage::load`]'s return values on failure. + pub fn load(&mut self, path: &Path) -> Result { + let package = TdlPackage::load(path)?; + if self.packages.contains_key(package.name()) { + return Err(ExecutorError::DuplicatePackage(package.name().to_owned())); + } + let name_key = package.name().to_owned(); + let inserted = self.packages.entry(name_key).or_insert(package); + Ok(inserted.name().to_owned()) + } + + /// # Returns + /// + /// The package registered under `package_name`, or `None` if no such package is loaded. + #[must_use] + pub fn get(&self, package_name: &str) -> Option<&TdlPackage> { + self.packages.get(package_name) + } + + /// # Returns + /// + /// An iterator over the names of all currently loaded packages, in unspecified order. + pub fn package_names(&self) -> impl Iterator { + self.packages.keys().map(String::as_str) + } +} + +/// FFI signature of `__spider_tdl_package_get_version`. +type GetVersionFn = unsafe extern "C" fn() -> Version; + +/// FFI signature of `__spider_tdl_package_get_name`. +type GetNameFn = unsafe extern "C" fn() -> CCharArray<'static>; + +/// FFI signature of `__spider_tdl_package_execute`. +type ExecuteFn = + unsafe extern "C" fn(CCharArray<'_>, CByteArray<'_>, CByteArray<'_>) -> TaskExecutionResult; + +/// FFI symbol name (NUL-terminated) for the package's compile-time `spider-tdl` version. +const SYM_GET_VERSION: &[u8] = b"__spider_tdl_package_get_version\0"; + +/// FFI symbol name (NUL-terminated) for the package's declared name. +const SYM_GET_NAME: &[u8] = b"__spider_tdl_package_get_name\0"; + +/// FFI symbol name (NUL-terminated) for the per-task dispatcher. +const SYM_EXECUTE: &[u8] = b"__spider_tdl_package_execute\0"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manager_load_nonexistent_path_returns_library_load_error() { + let mut manager = TdlPackageManager::new(); + let result = manager.load(Path::new("/this/path/does/not/exist.so")); + let err = result.expect_err("expected `InvalidLibrary` error for missing file"); + assert!( + matches!(err, ExecutorError::InvalidLibrary(_)), + "unexpected error variant: {err:?}", + ); + assert_eq!(manager.package_names().count(), 0); + } +} diff --git a/components/spider-tdl-derive/src/task_macro.rs b/components/spider-tdl-derive/src/task_macro.rs index 2af2a8c2d..f932aeb8f 100644 --- a/components/spider-tdl-derive/src/task_macro.rs +++ b/components/spider-tdl-derive/src/task_macro.rs @@ -3,8 +3,11 @@ //! The macro replaces the annotated function with three items: //! //! 1. A unit marker struct that shares the function's identifier and visibility. -//! 2. A private params struct holding the non-context parameters, with -//! `#[derive(serde::Deserialize)]` so the runtime can rebuild it from wire bytes. +//! 2. A params struct holding the non-context parameters: +//! * This struct shares the function's visibility, so it never leaks through the public +//! `Task::Params` associated type. +//! * This struct derives `serde::Deserialize` so that the runtime can rebuild it from +//! wire-format serialized byte sequence. //! 3. An `impl spider_tdl::Task` for the marker struct that wires the params back into the //! user-authored function body. //! @@ -156,7 +159,7 @@ pub fn expand(attr: &TaskAttr, func: &ItemFn) -> syn::Result { #[allow(non_camel_case_types)] #[derive(::serde::Deserialize)] - struct #params_struct_name { + #vis struct #params_struct_name { #(#param_fields,)* } @@ -400,7 +403,7 @@ mod tests { #[allow(non_camel_case_types)] #[derive(::serde::Deserialize)] - struct __add_params { + pub(crate) struct __add_params { a: int32, b: int32, } diff --git a/components/spider-tdl/Cargo.toml b/components/spider-tdl/Cargo.toml index c894ed08b..ac7e4c93b 100644 --- a/components/spider-tdl/Cargo.toml +++ b/components/spider-tdl/Cargo.toml @@ -13,6 +13,7 @@ path = "tests/test_task_macro.rs" required-features = ["derive"] [dependencies] +const-str = "1.1.0" rmp-serde = "1.3.1" serde = { version = "1.0.228", features = ["derive"] } spider-core = { path = "../spider-core" } diff --git a/components/spider-tdl/src/lib.rs b/components/spider-tdl/src/lib.rs index 60f3f138e..f413320e5 100644 --- a/components/spider-tdl/src/lib.rs +++ b/components/spider-tdl/src/lib.rs @@ -1,8 +1,10 @@ pub mod error; pub mod ffi; +pub mod register; pub mod r#std; pub mod task; pub mod task_context; +pub mod version; pub mod wire; pub use error::TdlError; @@ -10,3 +12,4 @@ pub use error::TdlError; pub use spider_tdl_derive::task; pub use task::{ExecutionResult, Task, TaskHandler, TaskHandlerImpl}; pub use task_context::TaskContext; +pub use version::Version; diff --git a/components/spider-tdl/src/register.rs b/components/spider-tdl/src/register.rs new file mode 100644 index 000000000..846ef0d7a --- /dev/null +++ b/components/spider-tdl/src/register.rs @@ -0,0 +1,223 @@ +//! Task registration macro and supporting helpers. +//! +//! The [`register_tdl_package!`] macro is invoked once per TDL package. It expands to: +//! +//! * A `LazyLock>>` dispatch table populated on first +//! lookup, mapping each task's `NAME` to a [`TaskHandler`](crate::TaskHandler) trait object. +//! Runtime lookups are O(1) and dispatch goes through the trait vtable. +//! * A single assertion that rejects duplicate `NAME`s with a `const_eval` panic at build time. The +//! hash map itself is built only once at runtime; uniqueness is enforced at compile time and is +//! independent of the runtime structure. +//! * Three `extern "C"` entry points consumed by the package manager via `dlsym`: +//! * `__spider_tdl_package_get_version` +//! * `__spider_tdl_package_get_name` +//! * `__spider_tdl_package_execute` +//! +//! The helpers in this module are public only so they are reachable from macro expansions in +//! downstream crates. They are not part of the user-facing API. As a result, their docstrings are +//! marked as `hidden`. + +use crate::TdlError; + +/// Registers a TDL package's tasks and exports the three C-FFI entry points consumed by the task +/// executor. +/// +/// Invoke once per package, at module scope: +/// +/// ```ignore +/// spider_tdl::register_tdl_package! { +/// package_name: "complex-number", +/// tasks: [add, sub, mul, div, always_fail], +/// } +/// ``` +/// +/// Each entry in `tasks` must name a type that implements [`Task`](crate::Task) (typically a marker +/// struct produced by the `#[task]` attribute macro). +/// +/// # Name Uniqueness +/// +/// All tasks registered within a single package must have distinct `NAME`s. This check happens in +/// compile time. +/// +/// # Generated FFI symbols +/// +/// * `__spider_tdl_package_get_version` returns the [`Version`](crate::Version) of `spider-tdl` the +/// package was compiled against. +/// * `__spider_tdl_package_get_name` returns the package name passed to the macro, as a borrowed +/// [`CCharArray`](crate::ffi::CCharArray). +/// * `__spider_tdl_package_execute` dispatches a task by name for execution and returns a +/// [`TaskExecutionResult`](crate::ffi::TaskExecutionResult). +#[macro_export] +macro_rules! register_tdl_package { + ( + package_name: $package_name:expr, + tasks: [$($task:path),* $(,)?] $(,)? + ) => { + const __SPIDER_TDL_PACKAGE_NAME: &str = $package_name; + + const _TASK_NAME_UNIQUENESS_CHECK: () = $crate::register::assert_unique_task_names(&[ + $( + <$task as $crate::Task>::NAME, + )* + ]); + + static __SPIDER_TDL_REGISTRY: ::std::sync::LazyLock< + ::std::collections::HashMap< + &'static str, + ::std::boxed::Box, + >, + > = ::std::sync::LazyLock::new(|| { + ::std::collections::HashMap::from([ + $( + ( + <$task as $crate::Task>::NAME, + ::std::boxed::Box::new($crate::TaskHandlerImpl::<$task>::new()) + as ::std::boxed::Box, + ), + )* + ]) + }); + + #[unsafe(no_mangle)] + pub extern "C" fn __spider_tdl_package_get_version() -> $crate::Version { + $crate::Version::SPIDER_TDL + } + + #[unsafe(no_mangle)] + pub extern "C" fn __spider_tdl_package_get_name() -> $crate::ffi::CCharArray<'static> { + $crate::ffi::CCharArray::from_utf8(__SPIDER_TDL_PACKAGE_NAME) + } + + #[unsafe(no_mangle)] + pub extern "C" fn __spider_tdl_package_execute( + name: $crate::ffi::CCharArray<'_>, + raw_ctx: $crate::ffi::CByteArray<'_>, + raw_inputs: $crate::ffi::CByteArray<'_>, + ) -> $crate::ffi::TaskExecutionResult { + let name_str: &str = match name.as_utf8() { + ::std::result::Result::Ok(s) => s, + ::std::result::Result::Err(_) => { + let err = $crate::TdlError::DeserializationError( + "task name is not valid UTF-8".to_owned(), + ); + let bytes = $crate::register::serialize_error_payload(&err); + return $crate::ffi::TaskExecutionResult::from_error(bytes); + } + }; + + let raw_ctx_slice: &[u8] = &raw_ctx; + let raw_inputs_slice: &[u8] = &raw_inputs; + + match __SPIDER_TDL_REGISTRY.get(name_str) { + ::std::option::Option::Some(handler) => { + match handler.execute_raw(raw_ctx_slice, raw_inputs_slice) { + ::std::result::Result::Ok(output_bytes) => { + $crate::ffi::TaskExecutionResult::from_outputs(output_bytes) + } + ::std::result::Result::Err(error_bytes) => { + $crate::ffi::TaskExecutionResult::from_error(error_bytes) + } + } + } + ::std::option::Option::None => { + let err = $crate::TdlError::TaskNotFound(name_str.to_owned()); + let bytes = $crate::register::serialize_error_payload(&err); + $crate::ffi::TaskExecutionResult::from_error(bytes) + } + } + } + }; +} + +/// Compile-time check that all task `NAME`s are distinct. +/// +/// The [`register_tdl_package!`] macro emits a single `const _: () = assert_unique_task_names(...)` +/// call passing every registered task's `NAME`. The helper runs an O(N²) nested loop entirely +/// during const evaluation; a duplicate `NAME` aborts the build with a `const_eval` panic. +/// +/// The panic message is static (can't include the conflicting `NAME` value) because multi-arg +/// formatting in const panic — `const_format_args!` — is still unstable. The single-arg form is +/// stable, but doesn't help here since we'd need to name both colliding tasks. +/// +/// # Panics +/// +/// Panics in const evaluation if any two `names` are equal. +#[doc(hidden)] +pub const fn assert_unique_task_names(names: &[&'static str]) { + let mut i = 0; + while i < names.len() { + let mut j = i + 1; + while j < names.len() { + assert!( + !const_str::equal!(names[i], names[j]), + "two registered tasks share the same NAME — check the `#[task(name = ...)]` \ + attributes in the most recent `register_tdl_package!` invocation", + ); + j += 1; + } + i += 1; + } +} + +/// Serializes a [`TdlError`] into the byte payload returned across the FFI boundary. +/// +/// Used by the `register_tdl_package!` expansion when reporting `TaskNotFound` and other errors +/// that originate inside the FFI dispatcher rather than inside a user task. +/// +/// # Returns +/// +/// The msgpack-encoded [`TdlError`] bytes. +/// +/// # Panics +/// +/// Panics if [`rmp_serde::to_vec`] fails to serialize the error. Msgpack encoding of [`TdlError`] +/// (which only contains a [`String`] payload) should not fail in practice. +#[doc(hidden)] +#[must_use] +pub fn serialize_error_payload(err: &TdlError) -> Vec { + rmp_serde::to_vec(err).expect("failed to serialize `TdlError` as msgpack") +} + +#[cfg(test)] +mod tests { + use std::panic; + + use super::*; + + #[test] + fn assert_unique_task_names_passes_when_all_distinct() { + let names: &[&str] = &["foo::a", "foo::b", "foo::c"]; + let result = panic::catch_unwind(|| assert_unique_task_names(names)); + assert!(result.is_ok(), "expected no panic for distinct names"); + } + + #[test] + fn assert_unique_task_names_passes_for_empty_input() { + let names: &[&str] = &[]; + let result = panic::catch_unwind(|| assert_unique_task_names(names)); + assert!(result.is_ok(), "expected no panic for empty input"); + } + + #[test] + fn assert_unique_task_names_panics_on_duplicate() { + let names: &[&str] = &["foo::a", "foo::dup", "foo::dup"]; + let payload = panic::catch_unwind(|| assert_unique_task_names(names)) + .expect_err("expected panic on duplicate NAME"); + let msg = payload + .downcast_ref::() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&'static str>().copied()) + .expect("panic payload was neither `String` nor `&'static str`"); + assert!( + msg.contains("two registered tasks share the same NAME"), + "unexpected panic payload: {msg}", + ); + } + + #[test] + fn assert_unique_task_names_is_const_callable() { + /// Confirms `assert_unique_task_names` is usable in a `const` context (i.e. compile-time). + /// If this fails to compile, the macro's `const _` invocation is also broken. + const _: () = assert_unique_task_names(&["foo::a", "foo::b"]); + } +} diff --git a/components/spider-tdl/src/version.rs b/components/spider-tdl/src/version.rs new file mode 100644 index 000000000..ae4285b02 --- /dev/null +++ b/components/spider-tdl/src/version.rs @@ -0,0 +1,111 @@ +//! Semantic version of the `spider-tdl` ABI shared across the TDL package / task executor C-FFI +//! boundary. +//! +//! [`Version`] is `#[repr(C)]` so it can be returned directly from a TDL package's +//! `__spider_tdl_package_get_version` FFI entry point. The package manager reads the value via +//! `dlsym` at load time and refuses to install packages whose declared version is incompatible +//! with the executor's [`Version::SPIDER_TDL`]. + +/// `#[repr(C)]` semantic-version triple shared across the TDL package / task executor FFI boundary. +/// +/// The struct is intentionally `Copy` so it can be returned by value from an `extern "C"` function. +/// Compatibility is decided by [`Self::is_compatible_with`]. +#[repr(C)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct Version { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl Version { + /// Compile-time `spider-tdl` version, derived from the crate's Cargo manifest via + /// `CARGO_PKG_VERSION_*` environment variables. + pub const SPIDER_TDL: Self = Self { + major: const_str::parse!(env!("CARGO_PKG_VERSION_MAJOR"), u32), + minor: const_str::parse!(env!("CARGO_PKG_VERSION_MINOR"), u32), + patch: const_str::parse!(env!("CARGO_PKG_VERSION_PATCH"), u32), + }; + + /// Constructs a [`Version`] from raw components. + /// + /// # Returns + /// + /// A [`Version`] with the given components. + #[must_use] + pub const fn new(major: u32, minor: u32, patch: u32) -> Self { + Self { + major, + minor, + patch, + } + } + + /// Decides whether `self` (the executor) can load a package built against `other`. + /// + /// The rule follows the standard semver convention: + /// + /// * For pre-1.0 versions (`major == 0`), each minor bump is treated as breaking, so both + /// `major` and `minor` must match. + /// * For post-1.0 versions, only `major` must match. Minor and patch differences are considered + /// backward compatible. + /// + /// # Returns + /// + /// Whether `other` is compatible with `self` under the rule above. + #[must_use] + pub const fn is_compatible_with(&self, other: &Self) -> bool { + if self.major == 0 { + self.major == other.major && self.minor == other.minor + } else { + self.major == other.major + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spider_tdl_version_matches_cargo_pkg_version() { + let cargo_major: u32 = env!("CARGO_PKG_VERSION_MAJOR") + .parse() + .expect("parse major"); + let cargo_minor: u32 = env!("CARGO_PKG_VERSION_MINOR") + .parse() + .expect("parse minor"); + let cargo_patch: u32 = env!("CARGO_PKG_VERSION_PATCH") + .parse() + .expect("parse patch"); + assert_eq!(Version::SPIDER_TDL.major, cargo_major); + assert_eq!(Version::SPIDER_TDL.minor, cargo_minor); + assert_eq!(Version::SPIDER_TDL.patch, cargo_patch); + } + + #[test] + fn pre_one_zero_compatibility() { + let executor = Version::new(0, 1, 0); + assert!(executor.is_compatible_with(&Version::new(0, 1, 0))); + assert!(executor.is_compatible_with(&Version::new(0, 1, 99))); + assert!(!executor.is_compatible_with(&Version::new(0, 2, 0))); + assert!(!executor.is_compatible_with(&Version::new(1, 1, 0))); + } + + #[test] + fn post_one_zero_compatibility() { + let executor = Version::new(1, 2, 3); + assert!(executor.is_compatible_with(&Version::new(1, 0, 0))); + assert!(executor.is_compatible_with(&Version::new(1, 99, 99))); + assert!(!executor.is_compatible_with(&Version::new(2, 2, 3))); + assert!(!executor.is_compatible_with(&Version::new(0, 2, 3))); + } + + #[test] + fn copy_and_eq() { + let a = Version::new(1, 2, 3); + let b = a; + assert_eq!(a, b); + assert_ne!(a, Version::new(1, 2, 4)); + } +} diff --git a/examples/huntsman/complex/tasks/Cargo.toml b/examples/huntsman/complex/tasks/Cargo.toml new file mode 100644 index 000000000..71a5dfbc4 --- /dev/null +++ b/examples/huntsman/complex/tasks/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "huntsman-complex" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib"] +name = "huntsman_complex" +path = "src/lib.rs" + +[dependencies] +huntsman-complex-types = { path = "../types" } +serde = { version = "1.0.228", features = ["derive"] } +spider-tdl = { path = "../../../../components/spider-tdl", features = ["derive"] } diff --git a/examples/huntsman/complex/tasks/src/lib.rs b/examples/huntsman/complex/tasks/src/lib.rs new file mode 100644 index 000000000..4f6bc9452 --- /dev/null +++ b/examples/huntsman/complex/tasks/src/lib.rs @@ -0,0 +1,120 @@ +//! Reference TDL package: complex-vector arithmetic. + +mod task_decl { + use huntsman_complex_types::{Complex, ComplexVec}; + use spider_tdl::{TaskContext, TdlError, task}; + + #[task(name = "complex::add")] + pub fn add(_ctx: TaskContext, a: ComplexVec, b: ComplexVec) -> Result { + require_same_length(&a, &b, "add")?; + let items = a + .items + .iter() + .zip(b.items.iter()) + .map(|(x, y)| Complex { + re: x.re + y.re, + im: x.im + y.im, + }) + .collect(); + Ok(ComplexVec { items }) + } + + #[task(name = "complex::sub")] + pub fn sub(_ctx: TaskContext, a: ComplexVec, b: ComplexVec) -> Result { + require_same_length(&a, &b, "sub")?; + let items = a + .items + .iter() + .zip(b.items.iter()) + .map(|(x, y)| Complex { + re: x.re - y.re, + im: x.im - y.im, + }) + .collect(); + Ok(ComplexVec { items }) + } + + #[task(name = "complex::dot_product")] + pub fn dot_product( + _ctx: TaskContext, + a: ComplexVec, + b: ComplexVec, + ) -> Result { + require_same_length(&a, &b, "dot_product")?; + let mut acc = Complex { re: 0.0, im: 0.0 }; + for (x, y) in a.items.iter().zip(b.items.iter()) { + let p = complex_mul(*x, *y); + acc.re += p.re; + acc.im += p.im; + } + Ok(acc) + } + + #[task(name = "complex::cross_product")] + pub fn cross_product( + _ctx: TaskContext, + a: ComplexVec, + b: ComplexVec, + ) -> Result { + if a.items.len() != 3 || b.items.len() != 3 { + return Err(TdlError::ExecutionError(format!( + "cross_product: requires both vectors of length 3 (got lhs={}, rhs={})", + a.items.len(), + b.items.len(), + ))); + } + let csub = |x: Complex, y: Complex| Complex { + re: x.re - y.re, + im: x.im - y.im, + }; + let i = csub( + complex_mul(a.items[1], b.items[2]), + complex_mul(a.items[2], b.items[1]), + ); + let j = csub( + complex_mul(a.items[2], b.items[0]), + complex_mul(a.items[0], b.items[2]), + ); + let k = csub( + complex_mul(a.items[0], b.items[1]), + complex_mul(a.items[1], b.items[0]), + ); + Ok(ComplexVec { + items: vec![i, j, k], + }) + } + + #[task(name = "complex::always_fail")] + pub fn always_fail(_ctx: TaskContext) -> Result<(), TdlError> { + Err(TdlError::Custom("this task always fails".to_owned())) + } + + fn complex_mul(x: Complex, y: Complex) -> Complex { + Complex { + re: x.im.mul_add(-y.im, x.re * y.re), + im: x.im.mul_add(y.re, x.re * y.im), + } + } + + fn require_same_length(a: &ComplexVec, b: &ComplexVec, op: &str) -> Result<(), TdlError> { + if a.items.len() != b.items.len() { + return Err(TdlError::ExecutionError(format!( + "{op}: vector length mismatch (lhs={}, rhs={})", + a.items.len(), + b.items.len(), + ))); + } + Ok(()) + } +} + +spider_tdl::register_tdl_package! { + package_name: "complex", + tasks: [ + task_decl::add, + task_decl::sub, + task_decl::dot_product, + task_decl::cross_product, + task_decl::always_fail + ], +} diff --git a/examples/huntsman/complex/types/Cargo.toml b/examples/huntsman/complex/types/Cargo.toml new file mode 100644 index 000000000..bd52851ed --- /dev/null +++ b/examples/huntsman/complex/types/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "huntsman-complex-types" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "huntsman_complex_types" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1.0.228", features = ["derive"] } +spider-tdl = { path = "../../../../components/spider-tdl" } diff --git a/examples/huntsman/complex/types/src/lib.rs b/examples/huntsman/complex/types/src/lib.rs new file mode 100644 index 000000000..b9853700f --- /dev/null +++ b/examples/huntsman/complex/types/src/lib.rs @@ -0,0 +1,25 @@ +//! Wire-compatible data types shared between the `huntsman-complex` cdylib (which exposes the +//! `complex::*` tasks) and any downstream consumer that builds task inputs / decodes outputs. +//! +//! Splitting the types out of the cdylib lets the integration test crate depend on this rlib and +//! reuse the canonical struct definitions instead of declaring a parallel mirror. + +use serde::{Deserialize, Serialize}; +use spider_tdl::r#std::{List, double}; + +/// A complex number with [`double`] components. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Complex { + pub re: double, + pub im: double, +} + +/// A list of [`Complex`] values, used as the input/output type for every vector arithmetic task +/// exported by the `huntsman-complex` cdylib. +/// +/// Wrapping the [`List`] in a named struct keeps the wire-format payload one positional element per +/// task parameter regardless of how many complex numbers it carries. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ComplexVec { + pub items: List, +} diff --git a/taskfiles/test.yaml b/taskfiles/test.yaml index a553ac034..838070156 100644 --- a/taskfiles/test.yaml +++ b/taskfiles/test.yaml @@ -214,6 +214,7 @@ tasks: MARIADB_DATABASE: "{{.MARIADB_DATABASE}}" MARIADB_USERNAME: "{{.MARIADB_USERNAME}}" MARIADB_PASSWORD: "{{.MARIADB_PASSWORD}}" + SPIDER_TDL_PACKAGE_COMPLEX: "{{.G_RUST_BUILD_DIR}}/release/libhuntsman_complex.so" SPIDER_TEST_INSTRUMENT_OUTPUT_DIR: sh: "echo {{.G_BUILD_DIR}}/spider-instrument-$(uuidgen)" requires: @@ -225,6 +226,7 @@ tasks: - defer: "rm -rf ${SPIDER_TEST_INSTRUMENT_OUTPUT_DIR}" - |- . "{{.G_RUST_TOOLCHAIN_ENV_FILE}}" + cargo build --package huntsman-complex --release cargo nextest run --all --all-features --run-ignored all --release - |- for f in ${SPIDER_TEST_INSTRUMENT_OUTPUT_DIR}/*; do diff --git a/tests/huntsman/tdl-integration/Cargo.toml b/tests/huntsman/tdl-integration/Cargo.toml new file mode 100644 index 000000000..73d10a635 --- /dev/null +++ b/tests/huntsman/tdl-integration/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tdl-integration" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "tdl_integration" +path = "src/lib.rs" + +[[test]] +name = "complex" +path = "tests/complex.rs" + +[dev-dependencies] +anyhow = "1.0.98" +huntsman-complex-types = { path = "../../../examples/huntsman/complex/types" } +rmp-serde = "1.3.1" +spider-core = { path = "../../../components/spider-core" } +spider-task-executor = { path = "../../../components/spider-task-executor" } +spider-tdl = { path = "../../../components/spider-tdl" } diff --git a/tests/huntsman/tdl-integration/src/lib.rs b/tests/huntsman/tdl-integration/src/lib.rs new file mode 100644 index 000000000..b42fb5460 --- /dev/null +++ b/tests/huntsman/tdl-integration/src/lib.rs @@ -0,0 +1,3 @@ +//! Workspace member that hosts cross-crate integration tests for the TDL package executor. +//! +//! Tests live under `tests/`. The library itself is intentionally empty. diff --git a/tests/huntsman/tdl-integration/tests/complex.rs b/tests/huntsman/tdl-integration/tests/complex.rs new file mode 100644 index 000000000..007cb5577 --- /dev/null +++ b/tests/huntsman/tdl-integration/tests/complex.rs @@ -0,0 +1,364 @@ +//! End-to-end tests for the TDL package executor against the `huntsman-complex` example crate. + +use huntsman_complex_types::{Complex, ComplexVec}; +use spider_core::types::{ + id::{JobId, ResourceGroupId, TaskId}, + io::TaskInput, +}; +use spider_task_executor::{ExecutorError, TdlPackageManager}; +use spider_tdl::{ + TaskContext, + TdlError, + Version, + wire::{TaskInputsSerializer, TaskOutputsSerializer}, +}; + +const PACKAGE_NAME: &str = "complex"; + +/// Reads the absolute path of the `huntsman-complex` cdylib from the [`SPIDER_TDL_PACKAGE_COMPLEX`] +/// environment variable. +/// +/// # Returns +/// +/// The path of `huntsman-complex` example cdylib. +fn lib_path() -> std::path::PathBuf { + std::env::var_os("SPIDER_TDL_PACKAGE_COMPLEX") + .map(std::path::PathBuf::from) + .expect("library not found") +} + +/// # Returns +/// +/// An encoded task context for testing. +fn encode_ctx() -> Vec { + let ctx = TaskContext { + job_id: JobId::new(), + task_id: TaskId::new(), + task_instance_id: 1, + resource_group_id: ResourceGroupId::new(), + }; + rmp_serde::to_vec(&ctx).expect("failed to serialize `TaskContext`") +} + +/// # Returns +/// +/// A wire-format-encoded empty input. +fn encode_no_inputs() -> Vec { + TaskInputsSerializer::new().release() +} + +/// # Returns +/// +/// An encoded complex vector with a pair of complex numbers. +fn encode_complex_vec_pair(a: &ComplexVec, b: &ComplexVec) -> anyhow::Result> { + let mut inputs = TaskInputsSerializer::new(); + inputs.append(TaskInput::ValuePayload(rmp_serde::to_vec(a)?))?; + inputs.append(TaskInput::ValuePayload(rmp_serde::to_vec(b)?))?; + Ok(inputs.release()) +} + +/// # Returns +/// +/// A decoded complex number from the byte buffer. +fn decode_complex(output_bytes: &[u8]) -> anyhow::Result { + let outputs = TaskOutputsSerializer::deserialize(output_bytes)?; + anyhow::ensure!( + outputs.len() == 1, + "expected exactly one output payload, got {}", + outputs.len() + ); + Ok(rmp_serde::from_slice(&outputs[0])?) +} + +/// # Returns +/// +/// A decoded complex vector from the byte buffer. +fn decode_complex_vec(output_bytes: &[u8]) -> anyhow::Result { + let outputs = TaskOutputsSerializer::deserialize(output_bytes)?; + anyhow::ensure!( + outputs.len() == 1, + "expected exactly one output payload, got {}", + outputs.len() + ); + Ok(rmp_serde::from_slice(&outputs[0])?) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn load_and_query_name() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + let name = manager.load(&path)?; + assert_eq!(name, PACKAGE_NAME); + let pkg = manager + .get(PACKAGE_NAME) + .expect("just-loaded package should be retrievable"); + assert_eq!(pkg.name(), PACKAGE_NAME); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn version_is_compatible() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + assert_eq!(pkg.version(), Version::SPIDER_TDL); + assert!(Version::SPIDER_TDL.is_compatible_with(&pkg.version())); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn duplicate_load_rejected() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let err = manager + .load(&path) + .expect_err("expected duplicate load to fail"); + assert!( + matches!(err, ExecutorError::DuplicatePackage(ref name) if name == PACKAGE_NAME), + "unexpected error: {err:?}", + ); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn add_round_trip() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + let a = ComplexVec { + items: vec![ + Complex { re: 1.0, im: 2.0 }, + Complex { re: 3.0, im: 4.0 }, + Complex { re: -1.5, im: 0.5 }, + ], + }; + let b = ComplexVec { + items: vec![ + Complex { re: 10.0, im: 20.0 }, + Complex { re: -3.0, im: 0.0 }, + Complex { re: 0.5, im: -0.5 }, + ], + }; + let outputs = pkg.execute_task( + "complex::add", + &encode_ctx(), + &encode_complex_vec_pair(&a, &b)?, + )?; + let result = decode_complex_vec(&outputs)?; + assert_eq!( + result, + ComplexVec { + items: vec![ + Complex { re: 11.0, im: 22.0 }, + Complex { re: 0.0, im: 4.0 }, + Complex { re: -1.0, im: 0.0 }, + ], + } + ); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn sub_round_trip() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + let a = ComplexVec { + items: vec![Complex { re: 5.0, im: 5.0 }, Complex { re: 1.0, im: 2.0 }], + }; + let b = ComplexVec { + items: vec![Complex { re: 1.0, im: 1.0 }, Complex { re: -1.0, im: -2.0 }], + }; + let outputs = pkg.execute_task( + "complex::sub", + &encode_ctx(), + &encode_complex_vec_pair(&a, &b)?, + )?; + let result = decode_complex_vec(&outputs)?; + assert_eq!( + result, + ComplexVec { + items: vec![Complex { re: 4.0, im: 4.0 }, Complex { re: 2.0, im: 4.0 },], + } + ); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn add_length_mismatch_returns_execution_error() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + let a = ComplexVec { + items: vec![Complex { re: 1.0, im: 0.0 }], + }; + let b = ComplexVec { + items: vec![Complex { re: 1.0, im: 0.0 }, Complex { re: 2.0, im: 0.0 }], + }; + let err = pkg + .execute_task( + "complex::add", + &encode_ctx(), + &encode_complex_vec_pair(&a, &b)?, + ) + .expect_err("expected length-mismatch error"); + let ExecutorError::TaskError(TdlError::ExecutionError(msg)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert!(msg.contains("length mismatch"), "unexpected message: {msg}"); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn dot_product_round_trip() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + // Σ a_i * b_i with: + // (1+2i)*(3+4i) = (3-8) + (4+6)i = -5 + 10i + // (5+6i)*(7+8i) = (35-48) + (40+42)i = -13 + 82i + // sum = -18 + 92i + let a = ComplexVec { + items: vec![Complex { re: 1.0, im: 2.0 }, Complex { re: 5.0, im: 6.0 }], + }; + let b = ComplexVec { + items: vec![Complex { re: 3.0, im: 4.0 }, Complex { re: 7.0, im: 8.0 }], + }; + let outputs = pkg.execute_task( + "complex::dot_product", + &encode_ctx(), + &encode_complex_vec_pair(&a, &b)?, + )?; + let result = decode_complex(&outputs)?; + assert_eq!( + result, + Complex { + re: -18.0, + im: 92.0 + } + ); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn cross_product_round_trip_real_basis() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + // Standard real-valued cross product: i_hat × j_hat = k_hat. + let a = ComplexVec { + items: vec![ + Complex { re: 1.0, im: 0.0 }, + Complex { re: 0.0, im: 0.0 }, + Complex { re: 0.0, im: 0.0 }, + ], + }; + let b = ComplexVec { + items: vec![ + Complex { re: 0.0, im: 0.0 }, + Complex { re: 1.0, im: 0.0 }, + Complex { re: 0.0, im: 0.0 }, + ], + }; + let outputs = pkg.execute_task( + "complex::cross_product", + &encode_ctx(), + &encode_complex_vec_pair(&a, &b)?, + )?; + let result = decode_complex_vec(&outputs)?; + assert_eq!( + result, + ComplexVec { + items: vec![ + Complex { re: 0.0, im: 0.0 }, + Complex { re: 0.0, im: 0.0 }, + Complex { re: 1.0, im: 0.0 }, + ], + } + ); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn cross_product_wrong_length_returns_error() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + let a = ComplexVec { + items: vec![Complex { re: 1.0, im: 0.0 }, Complex { re: 2.0, im: 0.0 }], + }; + let b = ComplexVec { + items: vec![Complex { re: 3.0, im: 0.0 }, Complex { re: 4.0, im: 0.0 }], + }; + let err = pkg + .execute_task( + "complex::cross_product", + &encode_ctx(), + &encode_complex_vec_pair(&a, &b)?, + ) + .expect_err("expected length-3 error"); + let ExecutorError::TaskError(TdlError::ExecutionError(msg)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert!(msg.contains("length 3"), "unexpected message: {msg}"); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn always_fail_propagates_custom_error() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + let err = pkg + .execute_task("complex::always_fail", &encode_ctx(), &encode_no_inputs()) + .expect_err("`always_fail` should always fail"); + assert!( + matches!(err, ExecutorError::TaskError(TdlError::Custom(_))), + "unexpected error: {err:?}", + ); + Ok(()) +} + +#[test] +#[ignore = "requires `huntsman-complex`"] +fn unknown_task_returns_task_not_found() -> anyhow::Result<()> { + let path = lib_path(); + let mut manager = TdlPackageManager::new(); + manager.load(&path)?; + let pkg = manager.get(PACKAGE_NAME).expect("package should be loaded"); + + let err = pkg + .execute_task("complex::nope", &encode_ctx(), &encode_no_inputs()) + .expect_err("unknown task should fail"); + let ExecutorError::TaskError(TdlError::TaskNotFound(name)) = &err else { + panic!("unexpected error: {err:?}"); + }; + assert_eq!(name, "complex::nope"); + Ok(()) +}