use crate::{multipart, Error, Response};
use maned_sys as sys;
use serde::Serialize;
use std::collections::BTreeMap;
use std::ffi::{CStr, CString};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::ptr;
use std::sync::atomic::{AtomicPtr, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
struct Handle {
ptr: AtomicPtr<sys::mnd_script>,
active: AtomicUsize,
}
struct CallGuard<'a> {
handle: &'a Handle,
raw: *mut sys::mnd_script,
}
impl Drop for CallGuard<'_> {
fn drop(&mut self) {
self.handle.active.fetch_sub(1, Ordering::SeqCst);
}
}
impl Handle {
fn close(&self) {
let p = self.ptr.swap(ptr::null_mut(), Ordering::SeqCst);
if !p.is_null() {
while self.active.load(Ordering::SeqCst) != 0 {
std::thread::sleep(std::time::Duration::from_micros(500));
}
unsafe { sys::mnd_close(p) }
}
}
fn acquire(&self) -> Result<CallGuard<'_>, Error> {
self.active.fetch_add(1, Ordering::SeqCst);
let p = self.ptr.load(Ordering::SeqCst);
if p.is_null() {
self.active.fetch_sub(1, Ordering::SeqCst);
return Err(Error::Library(
"this script handle is already closed — maned::shutdown() closes \
every live handle, and a Script that outlives it is inert"
.to_string(),
));
}
Ok(CallGuard { handle: self, raw: p })
}
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static LIVE: Mutex<Vec<(u64, Arc<Handle>)>> = Mutex::new(Vec::new());
fn register(handle: Arc<Handle>) -> u64 {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
if let Ok(mut live) = LIVE.lock() {
live.push((id, handle));
}
id
}
fn unregister(id: u64) {
if let Ok(mut live) = LIVE.lock() {
live.retain(|(other, _)| *other != id);
}
}
pub(crate) fn close_all() {
let handles: Vec<Arc<Handle>> = match LIVE.lock() {
Ok(mut live) => live.drain(..).map(|(_, h)| h).collect(),
Err(_) => return,
};
for h in handles {
h.close();
}
}
pub(crate) fn live_count() -> usize {
LIVE.lock().map(|l| l.len()).unwrap_or(0)
}
pub struct Script {
id: u64,
handle: Arc<Handle>,
notes: Vec<String>,
path: PathBuf,
routed: bool,
health: OnceLock<serde_json::Value>,
_not_sync: RoutedDeviceChannelsAreSingleConnection,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IoSpec {
pub name: String,
pub format: String,
pub default: Option<bool>,
}
#[allow(dead_code)]
struct RoutedDeviceChannelsAreSingleConnection(*const ());
impl RoutedDeviceChannelsAreSingleConnection {
const MARKER: Self = RoutedDeviceChannelsAreSingleConnection(ptr::null());
}
unsafe impl Send for Script {}
impl Script {
fn adopt(raw: *mut sys::mnd_script, path: PathBuf, notes: Vec<String>) -> Script {
let handle = Arc::new(Handle {
ptr: AtomicPtr::new(raw),
active: AtomicUsize::new(0),
});
let routed = unsafe { sys::mnd_script_is_routed(raw) } != 0;
let id = register(Arc::clone(&handle));
Script {
id,
handle,
notes,
path,
routed,
health: OnceLock::new(),
_not_sync: RoutedDeviceChannelsAreSingleConnection::MARKER,
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load_notes(&self) -> &[String] {
&self.notes
}
pub fn run<T: Serialize + ?Sized>(&mut self, payload: &T) -> Result<Response, Error> {
run_on(&self.handle, payload)
}
pub fn request(
&mut self,
method: &str,
target: &str,
content_type: Option<&str>,
body: &[u8],
) -> Result<Response, Error> {
request_on(&self.handle, method, target, content_type, body)
}
pub fn frame<T: Serialize + ?Sized>(&mut self, payload: &T) -> Result<Response, Error> {
frame_on(&self.handle, payload)
}
pub fn frame_csv<T: Serialize + ?Sized>(&mut self, payload: &T) -> Result<Vec<u8>, Error> {
frame_csv_on(&self.handle, payload)
}
pub fn run_files<K: AsRef<str>, V: AsRef<[u8]>>(
&mut self,
files: &[(K, V)],
) -> Result<BTreeMap<String, Vec<u8>>, Error> {
run_files_on(&self.handle, &self.health, files)
}
pub fn health(&self) -> Result<&serde_json::Value, Error> {
health_on(&self.handle, &self.health)
}
pub fn inputs(&self) -> Result<Vec<IoSpec>, Error> {
Ok(io_specs(self.health()?, "inputs"))
}
pub fn outputs(&self) -> Result<Vec<IoSpec>, Error> {
Ok(io_specs(self.health()?, "outputs"))
}
pub fn routes(&self) -> Result<Vec<String>, Error> {
Ok(self
.health()?
.get("routes")
.and_then(|r| r.as_array())
.map(|items| {
items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
pub fn is_routed(&self) -> bool {
self.routed
}
pub fn into_shared(self) -> Result<SharedScript, RoutedScriptError> {
if self.routed {
return Err(RoutedScriptError { script: self });
}
Ok(SharedScript { inner: self })
}
}
impl Drop for Script {
fn drop(&mut self) {
unregister(self.id);
self.handle.close();
}
}
impl std::fmt::Debug for Script {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Script")
.field("path", &self.path)
.field("routed", &self.routed)
.field("closed", &self.handle.ptr.load(Ordering::Relaxed).is_null())
.finish()
}
}
#[derive(Debug)]
pub struct RoutedScriptError {
script: Script,
}
impl RoutedScriptError {
pub fn into_script(self) -> Script {
self.script
}
pub fn path(&self) -> &Path {
self.script.path()
}
}
impl std::fmt::Display for RoutedScriptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} routes to a device, so it cannot be shared between threads: a \
routed script drives single-connection device channels, and two \
handles routing to the same device race that device's listener. \
Keep the Script and call it from one thread, or put it behind a \
Mutex and accept that you have serialized it.",
self.script.path().display()
)
}
}
impl std::error::Error for RoutedScriptError {}
impl From<RoutedScriptError> for Error {
fn from(e: RoutedScriptError) -> Error {
Error::Library(e.to_string())
}
}
pub struct SharedScript {
inner: Script,
}
unsafe impl Sync for SharedScript {}
impl SharedScript {
pub fn run<T: Serialize + ?Sized>(&self, payload: &T) -> Result<Response, Error> {
run_on(&self.inner.handle, payload)
}
pub fn request(
&self,
method: &str,
target: &str,
content_type: Option<&str>,
body: &[u8],
) -> Result<Response, Error> {
request_on(&self.inner.handle, method, target, content_type, body)
}
pub fn frame<T: Serialize + ?Sized>(&self, payload: &T) -> Result<Response, Error> {
frame_on(&self.inner.handle, payload)
}
pub fn frame_csv<T: Serialize + ?Sized>(&self, payload: &T) -> Result<Vec<u8>, Error> {
frame_csv_on(&self.inner.handle, payload)
}
pub fn run_files<K: AsRef<str>, V: AsRef<[u8]>>(
&self,
files: &[(K, V)],
) -> Result<BTreeMap<String, Vec<u8>>, Error> {
run_files_on(&self.inner.handle, &self.inner.health, files)
}
pub fn health(&self) -> Result<&serde_json::Value, Error> {
self.inner.health()
}
pub fn inputs(&self) -> Result<Vec<IoSpec>, Error> {
self.inner.inputs()
}
pub fn outputs(&self) -> Result<Vec<IoSpec>, Error> {
self.inner.outputs()
}
pub fn routes(&self) -> Result<Vec<String>, Error> {
self.inner.routes()
}
pub fn path(&self) -> &Path {
self.inner.path()
}
pub fn load_notes(&self) -> &[String] {
self.inner.load_notes()
}
pub fn is_routed(&self) -> bool {
false
}
pub fn into_script(self) -> Script {
self.inner
}
}
impl std::fmt::Debug for SharedScript {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SharedScript").field(&self.inner).finish()
}
}
fn run_on<T: Serialize + ?Sized>(handle: &Handle, payload: &T) -> Result<Response, Error> {
let body = serde_json::to_vec(payload).map_err(Error::Json)?;
request_on(handle, "POST", "/run.json", None, &body)?.into_result()
}
fn request_on(
handle: &Handle,
method: &str,
target: &str,
content_type: Option<&str>,
body: &[u8],
) -> Result<Response, Error> {
let guard = handle.acquire()?;
let c_method = cstring(method, "method")?;
let c_target = cstring(target, "target")?;
let c_type = content_type
.map(|t| cstring(t, "content_type"))
.transpose()?;
let type_ptr = c_type.as_ref().map_or(ptr::null(), |c| c.as_ptr());
let (body_ptr, body_len) = if body.is_empty() {
(ptr::null(), 0)
} else {
(body.as_ptr() as *const std::ffi::c_char, body.len())
};
let resp = unsafe {
sys::mnd_request(
guard.raw,
c_method.as_ptr(),
c_target.as_ptr(),
type_ptr,
body_ptr,
body_len,
)
};
drop(guard);
Response::from_raw(resp)
}
fn frame_on<T: Serialize + ?Sized>(handle: &Handle, payload: &T) -> Result<Response, Error> {
let body = serde_json::to_vec(payload).map_err(Error::Json)?;
request_on(handle, "POST", "/frame", None, &body)?.into_result()
}
fn frame_csv_on<T: Serialize + ?Sized>(handle: &Handle, payload: &T) -> Result<Vec<u8>, Error> {
let body = serde_json::to_vec(payload).map_err(Error::Json)?;
let response = request_on(handle, "POST", "/frame.csv", None, &body)?.into_result()?;
Ok(response.body().to_vec())
}
fn run_files_on<K: AsRef<str>, V: AsRef<[u8]>>(
handle: &Handle,
cache: &OnceLock<serde_json::Value>,
files: &[(K, V)],
) -> Result<BTreeMap<String, Vec<u8>>, Error> {
let boundary = multipart::boundary_for(files);
let body = multipart::encode(&boundary, files);
let content_type = format!("multipart/form-data; boundary={boundary}");
let response = request_on(handle, "POST", "/run", Some(&content_type), &body)?.into_result()?;
let answered = response.content_type();
if answered.to_ascii_lowercase().starts_with("multipart/") {
return multipart::decode(answered, response.body());
}
let names = io_specs(health_on(handle, cache)?, "outputs");
let key = match names.as_slice() {
[only] => only.name.clone(),
_ => "body".to_string(),
};
let mut parts = BTreeMap::new();
parts.insert(key, response.body().to_vec());
Ok(parts)
}
fn health_on<'a>(
handle: &Handle,
cache: &'a OnceLock<serde_json::Value>,
) -> Result<&'a serde_json::Value, Error> {
if let Some(document) = cache.get() {
return Ok(document);
}
let response = request_on(handle, "GET", "/health", None, &[])?.into_result()?;
let mut document: serde_json::Value =
serde_json::from_slice(response.body()).map_err(Error::Json)?;
if let Some(object) = document.as_object_mut() {
object.remove("requests_served");
}
let _ = cache.set(document);
Ok(cache
.get()
.expect("OnceLock was just set and is never cleared"))
}
fn io_specs(document: &serde_json::Value, key: &str) -> Vec<IoSpec> {
document
.get(key)
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
Some(IoSpec {
name: item.get("name")?.as_str()?.to_string(),
format: item
.get("format")
.and_then(|f| f.as_str())
.unwrap_or("")
.to_string(),
default: item.get("default").and_then(|d| d.as_bool()),
})
})
.collect()
})
.unwrap_or_default()
}
fn cstring(s: &str, what: &str) -> Result<CString, Error> {
CString::new(s).map_err(|_| {
Error::Library(format!(
"{what} contains an interior NUL byte, which a C string cannot carry"
))
})
}
#[derive(Debug, Clone)]
pub struct ScriptBuilder {
path: PathBuf,
flags: u32,
}
impl ScriptBuilder {
pub(crate) fn new(path: PathBuf) -> ScriptBuilder {
ScriptBuilder { path, flags: 0 }
}
pub fn synthesize_outputs(mut self, yes: bool) -> ScriptBuilder {
self.set(sys::MND_SYNTHESIZE_OUTPUTS, yes);
self
}
pub fn no_vm_fallback(mut self, yes: bool) -> ScriptBuilder {
self.set(sys::MND_NO_VM_FALLBACK, yes);
self
}
pub fn flags(&self) -> u32 {
self.flags
}
fn set(&mut self, bit: u32, yes: bool) {
if yes {
self.flags |= bit;
} else {
self.flags &= !bit;
}
}
pub fn open(self) -> Result<Script, Error> {
let c_path = CString::new(self.path.as_os_str().as_bytes()).map_err(|_| Error::Load {
path: self.path.clone(),
message: "path contains an interior NUL byte".to_string(),
})?;
let mut err: *mut std::ffi::c_char = ptr::null_mut();
let mut notes: *mut std::ffi::c_char = ptr::null_mut();
let raw = unsafe { sys::mnd_open(c_path.as_ptr(), self.flags, &mut err, &mut notes) };
let load_notes = take_owned_string(notes)
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default();
let message = take_owned_string(err);
if raw.is_null() {
return Err(Error::Load {
path: self.path.clone(),
message: with_cwd_clause(
message
.unwrap_or_else(|| "mnd_open returned NULL without a message".to_string()),
),
});
}
Ok(Script::adopt(raw, self.path, load_notes))
}
}
fn with_cwd_clause(message: String) -> String {
match std::env::current_dir() {
Ok(cwd) => format!(
"{}\n(note: relative paths inside a .mnd resolve against the process \
working directory, currently {} — prefer absolute paths in the script)",
message.trim_end(),
cwd.display()
),
Err(_) => message,
}
}
fn take_owned_string(p: *mut std::ffi::c_char) -> Option<String> {
if p.is_null() {
return None;
}
let owned = unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned();
unsafe { sys::mnd_free_string(p) };
Some(owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_flags_map_to_the_header_bits() {
let b = ScriptBuilder::new(PathBuf::from("x.mnd"));
assert_eq!(b.flags(), 0);
assert_eq!(b.clone().synthesize_outputs(true).flags(), 1);
assert_eq!(b.clone().no_vm_fallback(true).flags(), 2);
assert_eq!(
b.clone()
.synthesize_outputs(true)
.no_vm_fallback(true)
.flags(),
3
);
assert_eq!(
b.clone()
.synthesize_outputs(true)
.synthesize_outputs(false)
.flags(),
0
);
}
#[test]
fn interior_nul_is_refused_rather_than_truncated() {
let e = cstring("POST\0GET", "method").unwrap_err();
assert!(e.to_string().contains("interior NUL"), "{e}");
}
#[test]
fn close_drains_in_flight_calls_before_freeing() {
let dir = std::env::temp_dir().join(format!(
"maned_rs_drain_{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("drain.mnd");
std::fs::write(
&path,
"mnd::quantmax=30000;\nmnd::quantmin=-30000;\nmnd::quantres=0;\n\
calc::lambda_flow f(x) {\n x relu y =\n} return y;\n",
)
.unwrap();
let script = crate::builder(&path).synthesize_outputs(true).open().unwrap();
let shared = script.into_shared().unwrap();
let guard = shared.inner.handle.acquire().expect("live handle");
let closer = Arc::clone(&shared.inner.handle);
let t = std::thread::spawn(move || closer.close());
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(
!t.is_finished(),
"close() returned while a call guard was outstanding"
);
drop(guard); t.join().unwrap();
assert!(shared.inner.handle.acquire().is_err());
assert_eq!(shared.inner.handle.active.load(Ordering::SeqCst), 0);
let _ = std::fs::remove_dir_all(&dir);
}
}