From 70352d371779beeaa2b4c53a8da98c442c002d4e Mon Sep 17 00:00:00 2001 From: statusfailed Date: Mon, 31 Aug 2026 08:22:18 +0100 Subject: [PATCH 1/4] tweak runtime API so 'exec' is on `Artifact`... not `Runtime` --- README.md | 5 +- catena-lang/examples/mem.rs | 3 +- catena-lang/examples/runtime.rs | 8 +- catena-lang/examples/safe_runtime.rs | 18 ++-- catena-lang/src/runtime/artifact.rs | 36 ------- catena-lang/src/runtime/mod.rs | 20 ++-- catena-lang/src/runtime/runtime.rs | 82 ++++++---------- catena-lang/src/safe_runtime/mod.rs | 120 ++++++++++++++++------- catena-lang/src/safe_runtime/protocol.rs | 16 +++ catena-lang/tests/runtime.rs | 16 ++- 10 files changed, 160 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index d65bd64d..3583d9ab 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ fn main() -> anyhow::Result<()> { )) "#; - let runtime = Runtime::from_sources(stdlib::sources().chain([source]), GpuDialect::Hip)?; - let [result] = runtime.exec("two-plus-two", [])?; + let mut runtime = Runtime::new(GpuDialect::Hip)?; + let artifact = runtime.load_sources(stdlib::sources().chain([source]))?; + let [result] = artifact.exec("two-plus-two", [])?; let Value::U64(result) = result else { anyhow::bail!("two-plus-two returned non-u64 value: {result:?}"); }; diff --git a/catena-lang/examples/mem.rs b/catena-lang/examples/mem.rs index 089c50ff..8ac3f2b2 100644 --- a/catena-lang/examples/mem.rs +++ b/catena-lang/examples/mem.rs @@ -46,8 +46,7 @@ fn main() -> anyhow::Result<()> { let owned = runtime.mem_u64(&[3, 5])?; let borrowed = runtime.mem_u64(&[8, 13])?; - let [returned, sum] = runtime.exec( - &artifact, + let [returned, sum] = artifact.exec( "add-first-and-return-owned", [owned.into(), borrowed.as_ref().into()], )?; diff --git a/catena-lang/examples/runtime.rs b/catena-lang/examples/runtime.rs index a4aee0f8..22d4c2fd 100644 --- a/catena-lang/examples/runtime.rs +++ b/catena-lang/examples/runtime.rs @@ -26,14 +26,14 @@ fn main() -> anyhow::Result<()> { runtime.load(stdlib::paths_from(&root).chain([root.join("examples/example.hex")]))?; let plus_one = runtime.load_sources(stdlib::sources().chain([ARRAY_HEAD_PLUS_ONE]))?; - let [result] = runtime.exec(&artifact, "two-times-two", [])?; + let [result] = artifact.exec("two-times-two", [])?; let Value::U64(result) = result else { anyhow::bail!("two-times-two returned non-u64 value: {result:?}"); }; println!("two-times-two: {result}"); anyhow::ensure!(result == 4, "two-times-two returned {result}, expected 4"); - let [] = runtime.exec(&artifact, "require-true", [true.into()])?; + let [] = artifact.exec("require-true", [true.into()])?; // Input values for `array-head-u64` let values = [0x123456789abcdef0_u64, 7, 11]; @@ -48,7 +48,7 @@ fn main() -> anyhow::Result<()> { // Execute array-head-u64 with values above let input = runtime.mem_u64(&values)?; - let [head] = runtime.exec(&artifact, "array-head-u64", [input.as_ref().into()])?; + let [head] = artifact.exec("array-head-u64", [input.as_ref().into()])?; let Value::U64(head) = head else { anyhow::bail!("array-head-u64 returned non-u64 value: {head:?}"); }; @@ -62,7 +62,7 @@ fn main() -> anyhow::Result<()> { // Run the second .so's version of the same program with the device // allocation created above. - let [head_plus_one] = runtime.exec(&plus_one, "array-head-u64", [input.as_ref().into()])?; + let [head_plus_one] = plus_one.exec("array-head-u64", [input.as_ref().into()])?; let Value::U64(head_plus_one) = head_plus_one else { anyhow::bail!("second array-head-u64 returned non-u64 value: {head_plus_one:?}"); }; diff --git a/catena-lang/examples/safe_runtime.rs b/catena-lang/examples/safe_runtime.rs index f4efc8f7..3dfb8600 100644 --- a/catena-lang/examples/safe_runtime.rs +++ b/catena-lang/examples/safe_runtime.rs @@ -29,51 +29,51 @@ fn main() -> anyhow::Result<()> { ]))?; let add_one = runtime.load_sources(stdlib::sources().chain([ADD_ONE_SOURCE]))?; - let [added] = runtime.exec(&add_one, "add-one", [41_u64.into()])?; + let [added] = add_one.exec("add-one", [41_u64.into()])?; anyhow::ensure!(matches!(added, Value::U64(42))); - let [result] = runtime.exec(&artifact, "two-times-two", [])?; + let [result] = artifact.exec("two-times-two", [])?; let Value::U64(result) = result else { anyhow::bail!("two-times-two returned non-u64 value: {result:?}"); }; println!("two-times-two: {result}"); anyhow::ensure!(result == 4, "two-times-two returned {result}, expected 4"); - let [] = runtime.exec(&artifact, "require-true", [true.into()])?; + let [] = artifact.exec("require-true", [true.into()])?; let input = MemOwn::from_u64_slice(&[17, 19, 23], dialect)?; - let [head] = runtime.exec(&artifact, "array-head-u64", [input.as_ref().into()])?; + let [head] = artifact.exec("array-head-u64", [input.as_ref().into()])?; let Value::U64(head) = head else { anyhow::bail!("array-head-u64 returned non-u64 value: {head:?}"); }; anyhow::ensure!(head == 17, "array-head-u64 returned {head}, expected 17"); - let [head_again] = runtime.exec(&artifact, "array-head-u64", [input.as_ref().into()])?; + let [head_again] = artifact.exec("array-head-u64", [input.as_ref().into()])?; anyhow::ensure!(matches!(head_again, Value::U64(17))); // The child only imported a borrowed mapping, so the parent allocation is // still valid after successive calls. anyhow::ensure!(input.try_to_u64_vec()? == [17, 19, 23]); println!("array-head-u64 through IPC: {head}"); - let [returned] = runtime.exec(&artifact, "mem-own-identity", [input.into()])?; + let [returned] = artifact.exec("mem-own-identity", [input.into()])?; let Value::MemOwn(returned) = returned else { anyhow::bail!("mem-own-identity returned non-owned memory: {returned:?}"); }; anyhow::ensure!(returned.try_to_u64_vec()? == [17, 19, 23]); - let [materialized] = runtime.exec(&artifact, "materialize-indexes", [4_u64.into()])?; + let [materialized] = artifact.exec("materialize-indexes", [4_u64.into()])?; let Value::MemOwn(materialized) = materialized else { anyhow::bail!("materialize-indexes returned non-owned memory: {materialized:?}"); }; anyhow::ensure!(materialized.try_to_u64_vec()? == [1, 1, 1, 1]); let empty = MemOwn::from_u64_slice(&[], dialect)?; - let [empty] = runtime.exec(&artifact, "mem-own-identity", [empty.into()])?; + let [empty] = artifact.exec("mem-own-identity", [empty.into()])?; let Value::MemOwn(empty) = empty else { anyhow::bail!("empty mem-own-identity returned non-owned memory: {empty:?}"); }; anyhow::ensure!(empty.try_to_u64_vec()?.is_empty()); - match runtime.exec::<1, 0>(&artifact, "require-true", [false.into()]) { + match artifact.exec::<1, 0>("require-true", [false.into()]) { Err(SafeExecError::ChildTerminated { status, stderr }) => { anyhow::ensure!(!status.success(), "asserting child exited successfully"); anyhow::ensure!( diff --git a/catena-lang/src/runtime/artifact.rs b/catena-lang/src/runtime/artifact.rs index dbf8ec9f..6f98e288 100644 --- a/catena-lang/src/runtime/artifact.rs +++ b/catena-lang/src/runtime/artifact.rs @@ -4,7 +4,6 @@ use std::{ ffi::OsString, path::{Path, PathBuf}, process::{Command, ExitStatus}, - sync::atomic::{AtomicU64, Ordering}, }; use thiserror::Error; @@ -29,41 +28,6 @@ pub enum ArtifactError { }, } -/// Identifies one compiled Catena artifact belonging to a runtime. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Artifact { - runtime_id: RuntimeId, - index: usize, -} - -impl Artifact { - pub(crate) fn new(runtime_id: RuntimeId, index: usize) -> Self { - Self { runtime_id, index } - } - - pub(crate) fn belongs_to(&self, runtime_id: RuntimeId) -> bool { - self.runtime_id == runtime_id - } - - pub(crate) fn index(&self) -> usize { - self.index - } -} - -static NEXT_RUNTIME_ID: AtomicU64 = AtomicU64::new(1); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct RuntimeId(u64); - -impl RuntimeId { - pub(crate) fn new() -> Self { - let id = NEXT_RUNTIME_ID - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)) - .expect("runtime ID space exhausted"); - Self(id) - } -} - /// A shared object file created by compiling generated Catena GPU C++. #[derive(Debug)] pub(super) struct SharedObject { diff --git a/catena-lang/src/runtime/mod.rs b/catena-lang/src/runtime/mod.rs index de636623..665c486a 100644 --- a/catena-lang/src/runtime/mod.rs +++ b/catena-lang/src/runtime/mod.rs @@ -13,7 +13,7 @@ //! fn main() -> Result<(), Box> { //! let mut runtime = Runtime::new(GpuDialect::Hip)?; //! let artifact = runtime.load_sources(stdlib::sources().chain([PROGRAM]))?; -//! let [result] = runtime.exec(&artifact, "add-one", [41_u64.into()])?; +//! let [result] = artifact.exec("add-one", [41_u64.into()])?; //! let Value::U64(sum) = result else { //! panic!("`add-one` returned an unexpected value: {result:?}"); //! }; @@ -26,20 +26,20 @@ //! //! ## Quick reference //! -//! - [`Runtime::new`] creates an empty runtime. +//! - [`Runtime::new`] creates a process-local GPU context. //! - [`Runtime::load`] and [`Runtime::load_sources`] compile programs into an [`Artifact`]. -//! - [`Runtime::exec`] calls a program from a selected artifact with [`Value`] inputs. +//! - [`Artifact::exec`] calls a program from a compiled artifact with [`Value`] inputs. //! - [`Runtime::mem_u16`], [`Runtime::mem_u64`], and [`Runtime::mem_f32`] copy host slices into owned device memory. //! ### [`Value`] and Memory //! -//! Values are input to a catena program by supplying [`Value`]s to [`Runtime::exec`]. +//! Values are input to a catena program by supplying [`Value`]s to [`Artifact::exec`]. //! In addition to scalars like [`Value::U64`], you can supply two kinds of memory: //! [`MemRef`] and [`MemOwn`]. //! -//! Both are *length tagged device byte pointers* with differing ownership semantics: +//! Both are *length-tagged device byte pointers* with differing ownership semantics: //! //! - [`MemRef`]: A reference; ownership retained by Rust -//! - [`MemOwn`]: A *owned* buffer: ownership is *transferred to the catena program* +//! - [`MemOwn`]: An *owned* buffer: ownership is *transferred to the catena program* //! //! Raw device pointers can be wrapped with unsafe [`MemOwn::from_raw_parts`] or //! [`MemRef::from_raw_parts`], depending on ownership. For example, use [`MemRef::from_raw_parts`] @@ -51,7 +51,7 @@ pub mod value; /// Helpers for creating and freeing Catena memory values on program boundaries pub mod mem; -/// manage and run compiled catena programs +/// Compile and run catena programs pub mod runtime; /// Compile generated GPU C++ to a shared object. @@ -66,13 +66,11 @@ mod signature; //#[cfg(test)] //mod tests; -pub(crate) use artifact::RuntimeId; -pub use artifact::{Artifact, ArtifactError}; +pub use artifact::ArtifactError; pub use mem::MemError; pub use mem::MemOwn; pub use mem::MemRef; -pub use runtime::Runtime; -pub use runtime::{ExecError, InitError}; +pub use runtime::{Artifact, ExecError, InitError, Runtime}; #[cfg(feature = "experimental-catena-gpu")] pub use signature::GeneratedFunction; pub use value::Value; diff --git a/catena-lang/src/runtime/runtime.rs b/catena-lang/src/runtime/runtime.rs index f1fcd6f4..4a778b25 100644 --- a/catena-lang/src/runtime/runtime.rs +++ b/catena-lang/src/runtime/runtime.rs @@ -10,7 +10,7 @@ use libloading::Library; use libloading::os::unix::{Library as UnixLibrary, RTLD_LAZY, RTLD_LOCAL}; use serde::{Deserialize, Serialize}; -use super::artifact::{Artifact, ArtifactError, RuntimeId, SharedObject}; +use super::artifact::{ArtifactError, SharedObject}; use super::executor::{AbiValue, Executor, ExecutorError}; use super::mem::{MemError, MemOwn}; #[cfg(feature = "experimental-catena-gpu")] @@ -24,17 +24,21 @@ use crate::compile::CompileFailure; use crate::gpu::GpuApi; use metacat::theory::RawTheorySet; -/// Run catena programs with the C backend +/// A process-local GPU context for compiling artifacts and allocating memory. #[derive(Debug)] pub struct Runtime { /// GPU operations used to validate and release memory crossing the ABI. gpu: Arc, - runtime_id: RuntimeId, - artifacts: Vec, } +/// A compiled Catena program executable in this process. +/// +/// It owns its execution state and remains usable after its [`Runtime`] is +/// dropped. Native code remains mapped until process exit. #[derive(Debug)] -struct LoadedArtifact { +pub struct Artifact { + /// GPU operations used to validate and release memory crossing the ABI. + gpu: Arc, // Keep the tempdir-backed shared object alive for as long as the library is loaded. _shared_object: SharedObject, /// Prepared entry points in the loaded shared object. @@ -91,8 +95,6 @@ pub enum InitError { #[derive(Debug, Error, Serialize, Deserialize)] pub enum ExecError { - #[error("Artifact does not belong to this runtime")] - UnknownArtifact, #[error("Unknown source function '{0}'")] UnknownSourceFunction(String), #[error("Argument {index} expected {expected:?}, got {actual:?}")] @@ -122,12 +124,10 @@ impl Runtime { self.gpu.clone() } - /// Construct an empty runtime for the selected GPU dialect. + /// Create an empty runtime for the selected GPU dialect pub fn new(dialect: GpuDialect) -> Result { Ok(Self { gpu: GpuApi::load(dialect)?, - runtime_id: RuntimeId::new(), - artifacts: Vec::new(), }) } @@ -146,7 +146,7 @@ impl Runtime { self.load_raw_theories(raw_theories) } - /// Compile in-memory Catena source strings into a new artifact. + /// Compile in-memory Catena sources into a new artifact. pub fn load_sources<'a, I>(&mut self, sources: I) -> Result where I: IntoIterator, @@ -189,13 +189,12 @@ impl Runtime { InitError::LoadSymbol { symbol, source } } })?; - let artifact = Artifact::new(self.runtime_id, self.artifacts.len()); - self.artifacts.push(LoadedArtifact { + Ok(Artifact { + gpu: self.gpu.clone(), _shared_object: shared_object, executor, signatures: signature_table, - }); - Ok(artifact) + }) } /// Compile and load generated GPU source with its public entry-point ABI. @@ -242,36 +241,38 @@ impl Runtime { InitError::LoadSymbol { symbol, source } } })?; - let artifact = Artifact::new(self.runtime_id, self.artifacts.len()); - self.artifacts.push(LoadedArtifact { + Ok(Artifact { + gpu: self.gpu.clone(), _shared_object: shared_object, executor, signatures: signature_table, - }); - Ok(artifact) + }) } + /// Copy `u64` values into device memory for this context. pub fn mem_u64(&self, values: &[u64]) -> Result { MemOwn::from_u64_slice(values, self.gpu.dialect()) } + /// Copy `u16` values into device memory for this context. pub fn mem_u16(&self, values: &[u16]) -> Result { MemOwn::from_u16_slice(values, self.gpu.dialect()) } + /// Copy `f32` values into device memory for this context. pub fn mem_f32(&self, values: &[f32]) -> Result { MemOwn::from_f32_slice(values, self.gpu.dialect()) } +} - /// Run a source-level `program` definition from `artifact`. +impl Artifact { + /// Run a source-level `program` definition. pub fn exec<'a, const M: usize, const N: usize>( &self, - artifact: &Artifact, name: &str, args: [Value<'a>; M], ) -> Result<[Value<'static>; N], ExecError> { - let loaded = self.loaded_artifact(artifact)?; - let signature = loaded + let signature = self .signatures .get(name) .ok_or_else(|| ExecError::UnknownSourceFunction(name.to_string()))?; @@ -283,33 +284,25 @@ impl Runtime { }); } - self.exec_symbol(loaded, name, signature, args.into()) + self.exec_symbol(name, signature, args.into()) .map(|values| values.try_into().expect("output arity already validated")) } - /// Run a source-level `program` from `artifact` with dynamically sized - /// input and output collections. - /// - /// This is the public execution boundary for adapters such as SafeRuntime, - /// whose arities are known from a runtime protocol rather than const - /// generics. + /// Run a source-level `program` with dynamically sized inputs and outputs. pub fn exec_values<'a>( &self, - artifact: &Artifact, name: &str, args: Vec>, ) -> Result>, ExecError> { - let loaded = self.loaded_artifact(artifact)?; - let signature = loaded + let signature = self .signatures .get(name) .ok_or_else(|| ExecError::UnknownSourceFunction(name.to_string()))?; - self.exec_symbol(loaded, name, signature, args) + self.exec_symbol(name, signature, args) } fn exec_symbol<'a>( &self, - loaded: &LoadedArtifact, name: &str, signature: &FunctionSignature, args: Vec>, @@ -366,8 +359,7 @@ impl Runtime { }) .collect::>(); - loaded - .executor + self.executor .call(&signature.symbol, &raw_inputs, &mut raw_outputs); raw_outputs @@ -393,22 +385,6 @@ impl Runtime { } } } - - fn loaded_artifact(&self, artifact: &Artifact) -> Result<&LoadedArtifact, ExecError> { - if !artifact.belongs_to(self.runtime_id) { - return Err(ExecError::UnknownArtifact); - } - self.artifacts - .get(artifact.index()) - .ok_or(ExecError::UnknownArtifact) - } - - pub(crate) fn artifact_at(&self, index: usize) -> Result { - self.artifacts - .get(index) - .ok_or(ExecError::UnknownArtifact)?; - Ok(Artifact::new(self.runtime_id, index)) - } } fn ref_output(signatures: &SignatureTable) -> Option<(String, usize)> { diff --git a/catena-lang/src/safe_runtime/mod.rs b/catena-lang/src/safe_runtime/mod.rs index 1db392b3..de1bbffd 100644 --- a/catena-lang/src/safe_runtime/mod.rs +++ b/catena-lang/src/safe_runtime/mod.rs @@ -12,7 +12,7 @@ use std::{ io::{self, BufReader, Read}, path::{Path, PathBuf}, process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio}, - sync::Mutex, + sync::{Arc, Mutex}, thread::{self, JoinHandle}, }; @@ -30,7 +30,7 @@ use self::{ }; use crate::{ codegen::GpuDialect, - runtime::{Artifact, ExecError, MemError, MemOwn, Runtime, RuntimeId, Value}, + runtime::{Artifact as RuntimeArtifact, ExecError, MemError, MemOwn, Runtime, Value}, }; const CHILD_MODE_ENV: &str = "CATENA_SAFE_RUNTIME_CHILD"; @@ -68,11 +68,13 @@ pub enum SafeInitError { Memory(#[from] MemError), } -/// Execution failures reported by [`SafeRuntime`]. +/// Execution failures reported by a process-isolated [`Artifact`]. #[derive(Debug, Error)] pub enum SafeExecError { #[error(transparent)] Runtime(#[from] ExecError), + #[error("SafeRuntime artifact is no longer loaded in its child")] + UnknownArtifact, #[error("SafeRuntime transport failed: {0}")] Transport(String), #[error("SafeRuntime child returned an unexpected execution response")] @@ -96,20 +98,34 @@ pub enum ChildMainError { AlreadyInitialized, } -/// A process-isolated Catena runtime. +/// A process-isolated GPU context for compiling artifacts. /// /// The host executable must call [`run_safe_runtime_child_if_requested`] before /// parsing arguments or writing to stdout. `SafeRuntime` respawns that same /// executable and reserves its stdin/stdout for the worker protocol. #[derive(Debug)] pub struct SafeRuntime { + session: Arc, +} + +/// A compiled Catena program executable in the child process. +/// +/// It keeps the child alive after its [`SafeRuntime`] is dropped. Dropping it +/// releases child-side artifact state; native code remains mapped until exit. +#[derive(Debug)] +pub struct Artifact { + session: Arc, + remote_id: usize, +} + +#[derive(Debug)] +struct SafeSession { worker: Mutex, ipc: IpcTransport, - runtime_id: RuntimeId, } impl SafeRuntime { - /// Construct an empty process-isolated runtime. + /// Create a process-isolated context for the selected GPU dialect. pub fn new(dialect: GpuDialect) -> Result { let executable = env::current_exe().map_err(SafeInitError::CurrentExecutable)?; let ipc = IpcTransport::load(dialect)?; @@ -120,16 +136,17 @@ impl SafeRuntime { match worker.receive().map_err(map_init_worker_error)? { Response::Initialized(Ok(())) => Ok(Self { - worker: Mutex::new(worker), - ipc, - runtime_id: RuntimeId::new(), + session: Arc::new(SafeSession { + worker: Mutex::new(worker), + ipc, + }), }), Response::Initialized(Err(error)) => Err(SafeInitError::RemoteInitialization(error)), Response::Loaded(_) | Response::Executed(_) => Err(SafeInitError::UnexpectedResponse), } } - /// Load Catena programs from source paths. + /// Compile Catena source paths into a new artifact. pub fn load(&mut self, paths: I) -> Result where I: IntoIterator, @@ -144,7 +161,7 @@ impl SafeRuntime { self.load_owned_sources(sources) } - /// Load Catena programs from in-memory source strings. + /// Compile in-memory Catena sources into a new artifact. pub fn load_sources<'a, I>(&mut self, sources: I) -> Result where I: IntoIterator, @@ -153,31 +170,36 @@ impl SafeRuntime { } fn load_owned_sources(&mut self, sources: Vec) -> Result { - let worker = self + let mut worker = self + .session .worker - .get_mut() + .lock() .map_err(|_| SafeInitError::Transport("worker lock was poisoned".to_string()))?; worker .send(&Request::LoadSources { sources }) .map_err(map_init_worker_error)?; match worker.receive().map_err(map_init_worker_error)? { - Response::Loaded(Ok(index)) => Ok(Artifact::new(self.runtime_id, index)), + Response::Loaded(Ok(remote_id)) => Ok(Artifact { + session: self.session.clone(), + remote_id, + }), Response::Loaded(Err(error)) => Err(SafeInitError::RemoteLoad(error)), Response::Initialized(_) | Response::Executed(_) => { Err(SafeInitError::UnexpectedResponse) } } } +} +impl Artifact { /// Run a source-level program in the child process. pub fn exec<'a, const M: usize, const N: usize>( &self, - artifact: &Artifact, name: &str, args: [Value<'a>; M], ) -> Result<[Value<'static>; N], SafeExecError> { - self.exec_values(artifact, name, args.into())? + self.exec_values(name, args.into())? .try_into() .map_err(|_| SafeExecError::UnexpectedResponse) } @@ -185,15 +207,12 @@ impl SafeRuntime { /// Run a source-level program with dynamically sized inputs and outputs. pub fn exec_values<'a>( &self, - artifact: &Artifact, name: &str, args: Vec>, ) -> Result>, SafeExecError> { - if !artifact.belongs_to(self.runtime_id) { - return Err(SafeExecError::Runtime(ExecError::UnknownArtifact)); - } let (buffers, wire_args) = self.encode_parent_arguments(&args)?; let mut worker = self + .session .worker .lock() .map_err(|_| SafeExecError::Transport("worker lock was poisoned".to_string()))?; @@ -206,7 +225,7 @@ impl SafeRuntime { worker .send(&Request::Execute { - artifact: artifact.index(), + artifact: self.remote_id, name: name.to_string(), buffers, args: wire_args, @@ -219,6 +238,9 @@ impl SafeRuntime { Response::Executed(Err(RemoteExecError::Runtime(error))) => { return Err(SafeExecError::Runtime(error)); } + Response::Executed(Err(RemoteExecError::UnknownArtifact)) => { + return Err(SafeExecError::UnknownArtifact); + } Response::Executed(Err(RemoteExecError::Memory(error))) => { return Err(SafeExecError::Transport(format!( "child memory IPC failed: {error}" @@ -250,12 +272,12 @@ impl SafeRuntime { Value::U64(value) => WireValue::U64(*value), Value::F32(value) => WireValue::F32(*value), Value::MemOwn(memory) => { - if memory.dialect() != self.ipc.dialect() { + if memory.dialect() != self.session.ipc.dialect() { return Err(SafeExecError::Runtime( ExecError::IncompatibleDeviceMemory { index }, )); } - let exported = self.ipc.export_view(memory.as_ref())?; + let exported = self.session.ipc.export_view(memory.as_ref())?; let buffer_index = intern_buffer(&mut buffers, encode_ipc_buffer(exported)); WireValue::MemOwn { buffer: buffer_index, @@ -264,12 +286,12 @@ impl SafeRuntime { } } Value::MemRef(memory) => { - if memory.dialect() != self.ipc.dialect() { + if memory.dialect() != self.session.ipc.dialect() { return Err(SafeExecError::Runtime( ExecError::IncompatibleDeviceMemory { index }, )); } - let exported = self.ipc.export_view(*memory)?; + let exported = self.session.ipc.export_view(*memory)?; let buffer_index = intern_buffer(&mut buffers, encode_ipc_buffer(exported)); WireValue::MemRef { buffer: buffer_index, @@ -281,7 +303,7 @@ impl SafeRuntime { values.push(wire); } if !buffers.is_empty() { - self.ipc.synchronize()?; + self.session.ipc.synchronize()?; } Ok((buffers, values)) } @@ -291,9 +313,10 @@ impl SafeRuntime { &self, execution: WireExecution, ) -> Result>, SafeExecError> { - let imported = import_ipc_buffers(&self.ipc, execution.buffers).map_err(|error| { - SafeExecError::Transport(format!("child memory IPC failed: {error}")) - })?; + let imported = + import_ipc_buffers(&self.session.ipc, execution.buffers).map_err(|error| { + SafeExecError::Transport(format!("child memory IPC failed: {error}")) + })?; execution .values .into_iter() @@ -319,6 +342,19 @@ impl SafeRuntime { } } +impl Drop for Artifact { + fn drop(&mut self) { + let Ok(mut worker) = self.session.worker.lock() else { + return; + }; + if worker.termination().is_none() { + let _ = worker.send(&Request::ReleaseArtifact { + artifact: self.remote_id, + }); + } + } +} + fn encode_ipc_buffer(exported: ipc::ExportedIpcView) -> WireIpcBuffer { WireIpcBuffer { handle: exported.handle().map(|handle| handle.as_bytes().to_vec()), @@ -367,6 +403,7 @@ fn run_child_loop(mut reader: impl Read, mut writer: impl io::Write) -> Result<( let ipc = IpcTransport::from_runtime(&runtime); write_response(&mut writer, &Response::Initialized(Ok(())))?; + let mut artifacts: Vec> = Vec::new(); let mut pending_outputs = Vec::new(); while let Some(request) = read_request(&mut reader)? { match request { @@ -374,11 +411,20 @@ fn run_child_loop(mut reader: impl Read, mut writer: impl io::Write) -> Result<( Request::LoadSources { sources } => { let result = runtime .load_sources(sources.iter().map(String::as_str)) - .map(|artifact| artifact.index()) + .map(|artifact| { + let id = artifacts.len(); + artifacts.push(Some(artifact)); + id + }) .map_err(|error| error.to_string()); write_response(&mut writer, &Response::Loaded(result))?; } Request::Shutdown => return Ok(()), + Request::ReleaseArtifact { artifact } => { + if let Some(artifact) = artifacts.get_mut(artifact) { + *artifact = None; + } + } Request::ReleaseOutputs => { pending_outputs.clear(); } @@ -389,17 +435,16 @@ fn run_child_loop(mut reader: impl Read, mut writer: impl io::Write) -> Result<( args, } => { let response = if pending_outputs.is_empty() { - match runtime.artifact_at(artifact) { - Ok(artifact) => execute_in_child( - &runtime, + match artifacts.get(artifact).and_then(Option::as_ref) { + Some(artifact) => execute_in_child( &ipc, - &artifact, + artifact, &name, buffers, args, &mut pending_outputs, ), - Err(error) => Response::Executed(Err(RemoteExecError::Runtime(error))), + None => Response::Executed(Err(RemoteExecError::UnknownArtifact)), } } else { Response::Executed(Err(RemoteExecError::Memory( @@ -416,9 +461,8 @@ fn run_child_loop(mut reader: impl Read, mut writer: impl io::Write) -> Result<( /// Copies owned arguments into the child, runs the program, and prepares its outputs for export. fn execute_in_child( - runtime: &Runtime, ipc: &IpcTransport, - artifact: &Artifact, + artifact: &RuntimeArtifact, name: &str, buffers: Vec, wire_args: Vec, @@ -464,7 +508,7 @@ fn execute_in_child( Err(error) => return Response::Executed(Err(RemoteExecError::Memory(error))), }; - let values = match runtime.exec_values(artifact, name, args) { + let values = match artifact.exec_values(name, args) { Ok(values) => values, Err(error) => return Response::Executed(Err(RemoteExecError::Runtime(error))), }; diff --git a/catena-lang/src/safe_runtime/protocol.rs b/catena-lang/src/safe_runtime/protocol.rs index 8135e264..e9a2573d 100644 --- a/catena-lang/src/safe_runtime/protocol.rs +++ b/catena-lang/src/safe_runtime/protocol.rs @@ -21,6 +21,9 @@ pub(super) enum Request { buffers: Vec, args: Vec, }, + ReleaseArtifact { + artifact: usize, + }, ReleaseOutputs, Shutdown, } @@ -41,6 +44,7 @@ pub(super) struct WireExecution { #[derive(Debug, Serialize, Deserialize)] pub(super) enum RemoteExecError { Runtime(ExecError), + UnknownArtifact, Memory(String), } @@ -181,6 +185,18 @@ mod tests { )); } + #[test] + fn artifact_release_round_trips() { + let expected = artifact(); + let mut bytes = Vec::new(); + write_frame(&mut bytes, &Request::ReleaseArtifact { artifact: expected }).unwrap(); + + assert!(matches!( + read_frame(&mut bytes.as_slice()).unwrap(), + Some(Request::ReleaseArtifact { artifact }) if artifact == expected + )); + } + #[test] fn memory_request_uses_a_buffer_table_index() { let mut bytes = Vec::new(); diff --git a/catena-lang/tests/runtime.rs b/catena-lang/tests/runtime.rs index 2af48c9d..be80f9e0 100644 --- a/catena-lang/tests/runtime.rs +++ b/catena-lang/tests/runtime.rs @@ -33,7 +33,7 @@ impl TestRuntime { name: &str, args: [Value<'a>; M], ) -> Result<[Value<'static>; N], ExecError> { - self.runtime.exec(&self.artifact, name, args) + self.artifact.exec(name, args) } fn exec_values<'a>( @@ -41,7 +41,7 @@ impl TestRuntime { name: &str, args: Vec>, ) -> Result>, ExecError> { - self.runtime.exec_values(&self.artifact, name, args) + self.artifact.exec_values(name, args) } } @@ -85,16 +85,14 @@ fn multiple_artifacts_resolve_same_function_independently() -> anyhow::Result<() let identity = runtime.load_sources(stdlib::sources().chain([IDENTITY]))?; let add_one = runtime.load_sources(stdlib::sources().chain([ADD_ONE]))?; - let [first] = runtime.exec(&identity, "inspect", [41_u64.into()])?; - let [second] = runtime.exec(&add_one, "inspect", [41_u64.into()])?; + let [first] = identity.exec("inspect", [41_u64.into()])?; + let [second] = add_one.exec("inspect", [41_u64.into()])?; assert!(matches!(first, Value::U64(41))); assert!(matches!(second, Value::U64(42))); - let other_runtime = Runtime::new(dialect)?; - assert!(matches!( - other_runtime.exec::<1, 1>(&identity, "inspect", [41_u64.into()]), - Err(ExecError::UnknownArtifact) - )); + drop(runtime); + let [after_runtime_drop] = identity.exec("inspect", [41_u64.into()])?; + assert!(matches!(after_runtime_drop, Value::U64(41))); Ok(()) } From 6bac8b4c6b8221d25d9559ad02e8a8f0001183fe Mon Sep 17 00:00:00 2001 From: statusfailed Date: Mon, 31 Aug 2026 08:37:16 +0100 Subject: [PATCH 2/4] fix README --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3583d9ab..ff54b5be 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ fn main() -> anyhow::Result<()> { } ``` -The same example is available as -[catena-lang/examples/readme.rs](catena-lang/examples/readme.rs): +A more complete example is available as +[catena-lang/examples/runtime.rs](catena-lang/examples/runtime.rs): ```sh -cargo run -p catena-lang --example readme +cargo run -p catena-lang --example runtime ``` NOTE: by default this will run using the @@ -67,5 +67,5 @@ With [Nix](https://nix.dev/), you can run the example with the required dependencies as follows: ```sh -nix develop --command cargo run -p catena-lang --example readme +nix develop --command cargo run -p catena-lang --example runtime ``` From 4cccf3df632b33d4f9ca7276586a9954da8d07da Mon Sep 17 00:00:00 2001 From: statusfailed Date: Wed, 2 Sep 2026 05:25:03 +0100 Subject: [PATCH 3/4] update catena-gpu tests for new runtime api --- catena-gpu/tests/cases/launch.rs | 3 +-- catena-gpu/tests/cases/matmul.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/catena-gpu/tests/cases/launch.rs b/catena-gpu/tests/cases/launch.rs index 737c4346..4ad672b5 100644 --- a/catena-gpu/tests/cases/launch.rs +++ b/catena-gpu/tests/cases/launch.rs @@ -18,8 +18,7 @@ fn check_readable_sum(destination_size: usize, grid_x: u32, block_x: u32) -> any let source_values = [17_u64, 25, 99]; let source = runtime.mem_u64(&source_values)?; - let [destination] = runtime.exec( - &artifact, + let [destination] = artifact.exec( "fill-with-readable-sum", [ destination.into(), diff --git a/catena-gpu/tests/cases/matmul.rs b/catena-gpu/tests/cases/matmul.rs index bcfa2ce7..b59406a5 100644 --- a/catena-gpu/tests/cases/matmul.rs +++ b/catena-gpu/tests/cases/matmul.rs @@ -32,8 +32,7 @@ fn check_naive_u64_matmul( let a = runtime.mem_u64(&a_values)?; let b = runtime.mem_u64(&b_values)?; - let [c] = runtime.exec( - &artifact, + let [c] = artifact.exec( "naive-u64-matmul", [ c.into(), From 1045231f5eb77ef3c01f2766a0731ee9ab53d5a0 Mon Sep 17 00:00:00 2001 From: statusfailed Date: Wed, 2 Sep 2026 05:27:59 +0100 Subject: [PATCH 4/4] fix duplicate symbol prefix bug --- catena-gpu/src/codegen/gpu.rs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/catena-gpu/src/codegen/gpu.rs b/catena-gpu/src/codegen/gpu.rs index f642aa0d..f64fd1ce 100644 --- a/catena-gpu/src/codegen/gpu.rs +++ b/catena-gpu/src/codegen/gpu.rs @@ -382,7 +382,7 @@ fn input(a: &GpuAssign, index: usize) -> Result { pub(super) fn value_expr(value: &GpuValue) -> String { match value { GpuValue::Var(var) => var.name.clone(), - GpuValue::FnSymbol(target) => sanitize_ident(&format!("program.{target}")), + GpuValue::FnSymbol(symbol) => symbol.to_string(), } } @@ -441,18 +441,6 @@ fn render_ifc_call<'a>( )); } -pub(super) fn sanitize_ident(name: &str) -> String { - name.chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '_' - } - }) - .collect() -} - fn arity(a: &GpuAssign, expected: usize) -> GpuRenderError { GpuRenderError::InvalidArity { op: a.op.clone(),