diff --git a/cargo-eqts/src/main.rs b/cargo-eqts/src/main.rs index a4860ae..005daa5 100644 --- a/cargo-eqts/src/main.rs +++ b/cargo-eqts/src/main.rs @@ -1603,10 +1603,17 @@ fn render_bun(path: &str, functions: &[Function]) -> String { } output.push_str("});\n\n"); output.push_str(js_helpers()); + if functions + .iter() + .any(|function| function.abi == FunctionAbi::Json) + || reactive + { + output.push_str(bun_owned_buffer_helper()); + } output.push('\n'); if reactive { - output.push_str("const bindings = {\n eqtsHandleDispose(handle) { symbols.eqts_handle_dispose_v1(handle); },\n eqtsReactiveCancel(handle) { const status = symbols.eqts_reactive_cancel_v1(handle); if (status === 14) throw new EqtsError(\"UNKNOWN_HANDLE\", handle); },\n eqtsReactivePoll(handle) { const output = new BigUint64Array(3); const status = symbols.eqts_reactive_poll_v1(handle, ptr(output)); let value = null; try { if (output[0] !== 0n) value = JSON.parse(new TextDecoder().decode(new Uint8Array(toArrayBuffer(Number(output[0]), 0, Number(output[1]))).slice())); } finally { if (output[0] !== 0n) symbols.eqts_buffer_free_v1(output[0], output[1], output[2]); } if (status === 14) throw new EqtsError(\"UNKNOWN_HANDLE\", handle); return { status, value }; },\n};\n\n"); - output.push_str("bindings.eqtsHandleInvoke = (handle, method, __eqtsArguments) => { const input = new TextEncoder().encode(JSON.stringify({ method, arguments: __eqtsArguments })); const output = new BigUint64Array(3); const status = symbols.eqts_handle_invoke_v1(handle, ptr(input), BigInt(input.byteLength), ptr(output)); let text = \"\"; try { if (output[0] !== 0n) text = new TextDecoder().decode(new Uint8Array(toArrayBuffer(Number(output[0]), 0, Number(output[1]))).slice()); } finally { if (output[0] !== 0n) symbols.eqts_buffer_free_v1(output[0], output[1], output[2]); } checkStatus(status, method, text); return JSON.parse(text); };\n\n"); + output.push_str("const bindings = {\n eqtsHandleDispose(handle) { symbols.eqts_handle_dispose_v1(handle); },\n eqtsReactiveCancel(handle) { const status = symbols.eqts_reactive_cancel_v1(handle); if (status === 14) throw new EqtsError(\"UNKNOWN_HANDLE\", handle); },\n eqtsReactivePoll(handle) { const output = new BigUint64Array(3); const status = symbols.eqts_reactive_poll_v1(handle, ptr(output)); let value = null; try { if (output[0] !== 0n) value = JSON.parse(new TextDecoder().decode(__eqtsReadOwned(output))); } finally { if (output[0] !== 0n) symbols.eqts_buffer_free_v1(output[0], output[1], output[2]); } if (status === 14) throw new EqtsError(\"UNKNOWN_HANDLE\", handle); return { status, value }; },\n};\n\n"); + output.push_str("bindings.eqtsHandleInvoke = (handle, method, __eqtsArguments) => { const input = new TextEncoder().encode(JSON.stringify({ method, arguments: __eqtsArguments })); const output = new BigUint64Array(3); const status = symbols.eqts_handle_invoke_v1(handle, ptr(input), BigInt(input.byteLength), ptr(output)); let text = \"\"; try { if (output[0] !== 0n) text = new TextDecoder().decode(__eqtsReadOwned(output)); } finally { if (output[0] !== 0n) symbols.eqts_buffer_free_v1(output[0], output[1], output[2]); } checkStatus(status, method, text); return JSON.parse(text); };\n\n"); output.push_str("bindings.eqtsHandleInvokeAsync = (handle, method, __eqtsArguments) => { const input = new TextEncoder().encode(JSON.stringify({ method, arguments: __eqtsArguments })); const output = new BigUint64Array(1); const status = symbols.eqts_handle_invoke_async_v1(handle, ptr(input), BigInt(input.byteLength), ptr(output)); checkStatus(status, method); return output[0]; };\n\n"); output.push_str(reactive_runtime()); } @@ -1705,6 +1712,10 @@ fn js_helpers() -> &'static str { "export class EqtsError extends Error {\n constructor(code, value) {\n super(typeof value === \"string\" ? value : `eqts error: ${code}`);\n this.name = \"EqtsError\";\n this.code = code;\n this.value = value;\n }\n}\n\nfunction __eqtsNormalize(value) {\n if (value instanceof Map) return Object.fromEntries(Array.from(value, ([key, entry]) => [key, __eqtsNormalize(entry)]));\n if (Array.isArray(value)) return value.map(__eqtsNormalize);\n return value;\n}\n\nfunction checkStatus(status, name, detail) {\n if (status === 0) return;\n const code = { 1: \"RUST_PANIC\", 2: \"NULL_OUTPUT\", 3: \"INVALID_INPUT\", 4: \"ENCODE_FAILURE\" }[status] ?? \"ABI_ERROR\";\n throw new EqtsError(code, detail || `eqts call ${name} failed with ABI status ${status}`);\n}\n" } +fn bun_owned_buffer_helper() -> &'static str { + "function __eqtsReadOwned(output) {\n const address = output[0];\n const length = output[1];\n if (address === 0n || length === 0n) return new Uint8Array();\n if (length > 2147483647n) throw new EqtsError(\"ENCODE_FAILURE\", \"owned buffer length exceeds JavaScript limits\");\n return new Uint8Array(toArrayBuffer(address, 0, Number(length))).slice();\n}\n" +} + fn render_json_wrapper(output: &mut String, function: &Function, runtime: Runtime) { let parameters = function .parameters @@ -1748,7 +1759,7 @@ fn render_json_wrapper(output: &mut String, function: &Function, runtime: Runtim function.symbol ) .expect("writing to a string cannot fail"); - output.push_str(" let __eqtsText = \"\";\n try {\n if (__eqtsOutput[0] !== 0n) {\n const __eqtsBytes = new Uint8Array(toArrayBuffer(Number(__eqtsOutput[0]), 0, Number(__eqtsOutput[1]))).slice();\n __eqtsText = new TextDecoder().decode(__eqtsBytes);\n }\n } finally {\n if (__eqtsOutput[0] !== 0n) symbols.eqts_buffer_free_v1(__eqtsOutput[0], __eqtsOutput[1], __eqtsOutput[2]);\n }\n"); + output.push_str(" let __eqtsText = \"\";\n try {\n if (__eqtsOutput[0] !== 0n) {\n __eqtsText = new TextDecoder().decode(__eqtsReadOwned(__eqtsOutput));\n }\n } finally {\n if (__eqtsOutput[0] !== 0n) symbols.eqts_buffer_free_v1(__eqtsOutput[0], __eqtsOutput[1], __eqtsOutput[2]);\n }\n"); } Runtime::Deno => { output.push_str(" const __eqtsOutput = new BigUint64Array(3);\n"); @@ -2332,6 +2343,35 @@ mod tests { assert!(error.to_string().contains("ordinary u64 values")); } + #[test] + fn schema_v3_scalar_only_exports_parse() { + let functions = parse_metadata( + br#"{"schema_version":3,"capabilities":{"owned_values":true,"objects":false,"async_functions":false,"callbacks":false,"traits":false,"streams":false,"iterators":false},"functions":[{"module":"example","name":"add","symbol":"eqts_add","abi":"scalar","kind":{"kind":"function"},"parameters":[{"name":"a","ty":{"kind":"scalar","scalar":"u32"}},{"name":"b","ty":{"kind":"scalar","scalar":"u32"}}],"result":{"kind":"scalar","scalar":"u32"}}],"method_sets":[]}"#, + ) + .expect("scalar-only schema v3 metadata must parse"); + assert_eq!(functions.len(), 1); + assert_eq!(functions[0].name, "add"); + assert!(matches!(functions[0].kind, ExportKind::Function)); + } + + #[test] + fn bun_loaders_do_not_truncate_pointers_with_number() { + let function = owned_function(); + let bun = render_bun("./libfixture.dylib", std::slice::from_ref(&function)); + assert!(!bun.contains("Number(__eqtsOutput[0])")); + assert!(!bun.contains("Number(output[0])")); + assert!(bun.contains("toArrayBuffer(address, 0, Number(length))")); + let stream = render_bun( + "./libfixture.dylib", + std::slice::from_ref(&reactive_function(ExportKind::Stream { + item: Type::Owned(OwnedType::String), + })), + ); + assert!(!stream.contains("Number(output[0])")); + assert!(stream.contains("__eqtsReadOwned(output)")); + assert!(stream.contains("symbols.eqts_buffer_free_v1(output[0], output[1], output[2])")); + } + #[test] fn schema_v3_explicit_function_exports_parse_with_default_capabilities() { let functions = parse_metadata( @@ -2737,7 +2777,13 @@ mod tests { let bun = render_bun("./libfixture.dylib", std::slice::from_ref(&function)); assert!(bun.contains("new BigUint64Array(3)")); - assert!(bun.contains("toArrayBuffer(Number(__eqtsOutput[0]), 0, Number(__eqtsOutput[1]))")); + assert!( + bun.contains("toArrayBuffer(address, 0, Number(length))"), + "{bun}" + ); + assert!(!bun.contains("Number(__eqtsOutput[0])"), "{bun}"); + assert!(!bun.contains("Number(output[0])"), "{bun}"); + assert!(bun.contains("__eqtsReadOwned(__eqtsOutput)")); assert!(bun.contains( "symbols.eqts_buffer_free_v1(__eqtsOutput[0], __eqtsOutput[1], __eqtsOutput[2])" )); diff --git a/eq-ts/src/lib.rs b/eq-ts/src/lib.rs index bf6283d..b9271ce 100644 --- a/eq-ts/src/lib.rs +++ b/eq-ts/src/lib.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, VecDeque}; use std::future::Future; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; pub use eqts_macros::{ Enum, Record, async_export, callback, export, iterator, method, methods, object, stream, @@ -251,10 +251,10 @@ pub struct MethodRegistration { inventory::collect!(MethodRegistration); struct ReactiveEntry { - resource: Box, + resource: Arc>>, cancelled: bool, - callbacks: std::sync::Arc>>, - invoke: Option>>>, + callbacks: Arc>>, + invoke: Option>>>, } #[derive(Default)] @@ -273,25 +273,53 @@ fn reactive_registry() -> &'static Mutex { }) } -#[must_use] -pub fn register_reactive(resource: impl ReactiveResource) -> u64 { - let mut registry = reactive_registry() +fn lock_registry() -> std::sync::MutexGuard<'static, ReactiveRegistry> { + reactive_registry() .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let handle = registry.next; - registry.next = registry.next.checked_add(1).unwrap_or(1); + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn allocate_handle(registry: &mut ReactiveRegistry) -> u64 { + loop { + let handle = registry.next; + let Some(next) = registry.next.checked_add(1) else { + panic!("eqts reactive handle space exhausted"); + }; + registry.next = next; + if handle != 0 && !registry.entries.contains_key(&handle) { + return handle; + } + } +} + +fn insert_entry( + resource: Box, + callbacks: Arc>>, + invoke: Option>>>, +) -> u64 { + let mut registry = lock_registry(); + let handle = allocate_handle(&mut registry); registry.entries.insert( handle, ReactiveEntry { - resource: Box::new(resource), + resource: Arc::new(Mutex::new(resource)), cancelled: false, - callbacks: std::sync::Arc::new(Mutex::new(VecDeque::with_capacity(1))), - invoke: None, + callbacks, + invoke, }, ); handle } +#[must_use] +pub fn register_reactive(resource: impl ReactiveResource) -> u64 { + insert_entry( + Box::new(resource), + Arc::new(Mutex::new(VecDeque::with_capacity(1))), + None, + ) +} + struct IdleResource; impl ReactiveResource for IdleResource { fn poll(&mut self) -> Result { @@ -301,21 +329,11 @@ impl ReactiveResource for IdleResource { #[must_use] pub fn register_object(value: impl EqtsMethods) -> u64 { - let mut registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let handle = registry.next; - registry.next = registry.next.checked_add(1).unwrap_or(1); - registry.entries.insert( - handle, - ReactiveEntry { - resource: Box::new(IdleResource), - cancelled: false, - callbacks: std::sync::Arc::new(Mutex::new(VecDeque::with_capacity(1))), - invoke: Some(std::sync::Arc::new(Mutex::new(Box::new(value)))), - }, - ); - handle + insert_entry( + Box::new(IdleResource), + Arc::new(Mutex::new(VecDeque::with_capacity(1))), + Some(Arc::new(Mutex::new(Box::new(value)))), + ) } #[doc(hidden)] @@ -324,9 +342,7 @@ pub fn invoke_handle( method: &str, arguments: Vec, ) -> Result { - let mut registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut registry = lock_registry(); let entry = registry .entries .get_mut(&handle) @@ -349,9 +365,7 @@ pub fn invoke_handle_async( method: &str, arguments: Vec, ) -> Result { - let registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); + let registry = lock_registry(); let entry = registry .entries .get(&handle) @@ -372,26 +386,12 @@ pub fn invoke_handle_async( pub fn register_callback( factory: impl FnOnce(Callback) -> Box, ) -> u64 { - let queue = std::sync::Arc::new(Mutex::new(VecDeque::with_capacity(1))); + let queue = Arc::new(Mutex::new(VecDeque::with_capacity(1))); let resource = factory(Callback { - queue: std::sync::Arc::clone(&queue), + queue: Arc::clone(&queue), marker: std::marker::PhantomData, }); - let mut registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let handle = registry.next; - registry.next = registry.next.checked_add(1).unwrap_or(1); - registry.entries.insert( - handle, - ReactiveEntry { - resource, - cancelled: false, - callbacks: queue, - invoke: None, - }, - ); - handle + insert_entry(resource, queue, None) } struct FutureResource { @@ -419,8 +419,8 @@ where T: EqtsValue + Send, F: Future + Send + 'static, { - let state = std::sync::Arc::new(Mutex::new(None)); - let task_state = std::sync::Arc::clone(&state); + let state = Arc::new(Mutex::new(None)); + let task_state = Arc::clone(&state); run_future(async move { let value = future.await.into_json(); *task_state @@ -442,60 +442,64 @@ fn run_future(future: impl Future + 'static) { #[expect(clippy::missing_errors_doc)] pub fn enqueue_callback(handle: u64, value: impl EqtsValue) -> Result<(), String> { - let mut registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let entry = registry - .entries - .get_mut(&handle) - .ok_or_else(|| "unknown reactive handle".to_string())?; - let mut callbacks = entry - .callbacks + let callbacks = { + let registry = lock_registry(); + let entry = registry + .entries + .get(&handle) + .ok_or_else(|| "unknown reactive handle".to_string())?; + Arc::clone(&entry.callbacks) + }; + let encoded = value.into_json()?; + let mut queue = callbacks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !callbacks.is_empty() { + if !queue.is_empty() { return Err("callback buffer is full".into()); } - callbacks.push_back(value.into_json()?); + queue.push_back(encoded); Ok(()) } #[doc(hidden)] pub fn cancel_reactive(handle: u64) -> i32 { - let mut registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let Some(entry) = registry.entries.get_mut(&handle) else { - return ABI_OK; - }; - if !entry.cancelled { + let resource = { + let mut registry = lock_registry(); + let Some(entry) = registry.entries.get_mut(&handle) else { + return ABI_OK; + }; + if entry.cancelled { + return ABI_OK; + } entry.cancelled = true; - entry.resource.cancel(); - } + Arc::clone(&entry.resource) + }; + resource + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .cancel(); ABI_OK } #[doc(hidden)] +#[expect(clippy::must_use_candidate)] pub fn dispose_reactive(handle: u64) -> i32 { - reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .entries - .remove(&handle); + lock_registry().entries.remove(&handle); ABI_OK } #[doc(hidden)] pub fn poll_reactive(handle: u64) -> Result<(i32, Option>), i32> { - let mut registry = reactive_registry() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let entry = registry - .entries - .get_mut(&handle) - .ok_or(ABI_UNKNOWN_HANDLE)?; - let callback = entry - .callbacks + let (resource, callbacks, cancelled) = { + let registry = lock_registry(); + let entry = registry.entries.get(&handle).ok_or(ABI_UNKNOWN_HANDLE)?; + ( + Arc::clone(&entry.resource), + Arc::clone(&entry.callbacks), + entry.cancelled, + ) + }; + let callback = callbacks .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .pop_front(); @@ -504,10 +508,14 @@ pub fn poll_reactive(handle: u64) -> Result<(i32, Option>), i32> { .map(|value| (ABI_REACTIVE_CALLBACK, Some(value))) .map_err(|_| ABI_ENCODE_ERROR); } - if entry.cancelled { + if cancelled { return Ok((ABI_REACTIVE_DONE, None)); } - match entry.resource.poll() { + let poll = resource + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .poll(); + match poll { Ok(ReactivePoll::Pending) => Ok((ABI_REACTIVE_PENDING, None)), Ok(ReactivePoll::Done) => Ok((ABI_REACTIVE_DONE, None)), Ok(ReactivePoll::Ready(value)) => serde_json::to_vec(&value) @@ -520,6 +528,36 @@ pub fn poll_reactive(handle: u64) -> Result<(i32, Option>), i32> { } } +fn capabilities_from(functions: &[Function], method_sets: &[MethodSet]) -> Capabilities { + let mut capabilities = Capabilities { + owned_values: true, + objects: false, + async_functions: false, + callbacks: false, + traits: false, + streams: false, + iterators: false, + }; + for function in functions { + match function.kind { + ExportKind::Function => {} + ExportKind::Object { .. } => capabilities.objects = true, + ExportKind::Trait { .. } => capabilities.traits = true, + ExportKind::Async { .. } => capabilities.async_functions = true, + ExportKind::Callback { .. } => capabilities.callbacks = true, + ExportKind::Stream { .. } => capabilities.streams = true, + ExportKind::Iterator { .. } => capabilities.iterators = true, + } + } + if method_sets + .iter() + .any(|set| set.methods.iter().any(|method| method.asynchronous)) + { + capabilities.async_functions = true; + } + capabilities +} + macro_rules! scalar_value { ($type:ty, $scalar:ident) => { impl EqtsValue for $type { @@ -712,9 +750,10 @@ pub fn metadata_json() -> &'static [u8] { .map(|registration| (registration.describe)()) .collect::>(); method_sets.sort_unstable_by_key(|set| set.rust_type); + let capabilities = capabilities_from(&functions, &method_sets); serde_json::to_vec(&Metadata { schema_version: 3, - capabilities: Capabilities::default(), + capabilities, functions, method_sets, }) @@ -771,76 +810,119 @@ macro_rules! setup { } #[unsafe(no_mangle)] - pub extern "C" fn eqts_handle_dispose_v1(handle: u64) -> i32 { $crate::dispose_reactive(handle) } + pub extern "C" fn eqts_handle_dispose_v1(handle: u64) -> i32 { + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::dispose_reactive(handle))) { + Ok(status) => status, + Err(_) => $crate::ABI_PANIC, + } + } #[unsafe(no_mangle)] - pub extern "C" fn eqts_reactive_cancel_v1(handle: u64) -> i32 { $crate::cancel_reactive(handle) } + pub extern "C" fn eqts_reactive_cancel_v1(handle: u64) -> i32 { + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::cancel_reactive(handle))) { + Ok(status) => status, + Err(_) => $crate::ABI_PANIC, + } + } #[unsafe(no_mangle)] pub unsafe extern "C" fn eqts_reactive_poll_v1(handle: u64, output: *mut $crate::OwnedBuffer) -> i32 { if output.is_null() { return $crate::ABI_NULL_OUTPUT; } - match $crate::poll_reactive(handle) { - Ok((status, bytes)) => { + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::poll_reactive(handle))) { + Ok(Ok((status, bytes))) => { let buffer = bytes.map_or_else($crate::OwnedBuffer::empty, $crate::OwnedBuffer::from_bytes); // SAFETY: caller provides a valid writable output pointer. unsafe { output.write(buffer) }; status } - Err(status) => status, + Ok(Err(status)) => status, + Err(_) => $crate::ABI_PANIC, } } #[unsafe(no_mangle)] pub unsafe extern "C" fn eqts_handle_invoke_v1(handle: u64, input: *const u8, len: usize, output: *mut $crate::OwnedBuffer) -> i32 { if output.is_null() || (input.is_null() && len != 0) { return $crate::ABI_NULL_OUTPUT; } - let input = if len == 0 { &b"{}"[..] } else { unsafe { std::slice::from_raw_parts(input, len) } }; - let value: ::eqts::__private::Value = match ::eqts::__private::from_slice(input) { Ok(value) => value, Err(_) => return $crate::ABI_INVALID_INPUT }; - let Some(method) = value.get("method").and_then(::eqts::__private::Value::as_str) else { return $crate::ABI_INVALID_INPUT; }; - let arguments = value.get("arguments").and_then(::eqts::__private::Value::as_array).cloned().unwrap_or_default(); - match $crate::invoke_handle(handle, method, arguments).and_then(|value| ::eqts::__private::to_vec(&value).map_err(|error| error.to_string())) { - Ok(bytes) => { unsafe { output.write($crate::OwnedBuffer::from_bytes(bytes)) }; $crate::ABI_OK } - Err(error) => { unsafe { output.write($crate::OwnedBuffer::from_bytes(error.into_bytes())) }; $crate::ABI_INVALID_INPUT } + let operation = ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| -> ::std::result::Result<::std::vec::Vec, (i32, ::std::string::String)> { + let input = if len == 0 { &b"{}"[..] } else { unsafe { std::slice::from_raw_parts(input, len) } }; + let value: ::eqts::__private::Value = ::eqts::__private::from_slice(input).map_err(|error| ($crate::ABI_INVALID_INPUT, error.to_string()))?; + let Some(method) = value.get("method").and_then(::eqts::__private::Value::as_str) else { return Err(($crate::ABI_INVALID_INPUT, "invalid input".into())); }; + let arguments = value.get("arguments").and_then(::eqts::__private::Value::as_array).cloned().unwrap_or_default(); + $crate::invoke_handle(handle, method, arguments).and_then(|value| ::eqts::__private::to_vec(&value).map_err(|error| error.to_string())).map_err(|error| ($crate::ABI_INVALID_INPUT, error)) + })); + match operation { + Ok(Ok(bytes)) => { unsafe { output.write($crate::OwnedBuffer::from_bytes(bytes)) }; $crate::ABI_OK } + Ok(Err((status, error))) => { unsafe { output.write($crate::OwnedBuffer::from_bytes(error.into_bytes())) }; status } + Err(_) => { unsafe { output.write($crate::OwnedBuffer::empty()) }; $crate::ABI_PANIC } } } #[unsafe(no_mangle)] pub unsafe extern "C" fn eqts_handle_invoke_async_v1(handle: u64, input: *const u8, len: usize, output: *mut u64) -> i32 { if output.is_null() || (input.is_null() && len != 0) { return $crate::ABI_NULL_OUTPUT; } - let input = if len == 0 { &b"{}"[..] } else { unsafe { std::slice::from_raw_parts(input, len) } }; - let value: ::eqts::__private::Value = match ::eqts::__private::from_slice(input) { Ok(value) => value, Err(_) => return $crate::ABI_INVALID_INPUT }; - let Some(method) = value.get("method").and_then(::eqts::__private::Value::as_str) else { return $crate::ABI_INVALID_INPUT; }; - let arguments = value.get("arguments").and_then(::eqts::__private::Value::as_array).cloned().unwrap_or_default(); - match $crate::invoke_handle_async(handle, method, arguments) { Ok(future) => unsafe { ::eqts::__private::write_output(output, future) }, Err(_) => $crate::ABI_INVALID_INPUT } + let operation = ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| -> ::std::result::Result { + let input = if len == 0 { &b"{}"[..] } else { unsafe { std::slice::from_raw_parts(input, len) } }; + let value: ::eqts::__private::Value = ::eqts::__private::from_slice(input).map_err(|_| $crate::ABI_INVALID_INPUT)?; + let Some(method) = value.get("method").and_then(::eqts::__private::Value::as_str) else { return Err($crate::ABI_INVALID_INPUT); }; + let arguments = value.get("arguments").and_then(::eqts::__private::Value::as_array).cloned().unwrap_or_default(); + $crate::invoke_handle_async(handle, method, arguments).map_err(|_| $crate::ABI_INVALID_INPUT) + })); + match operation { + Ok(Ok(future)) => unsafe { ::eqts::__private::write_output(output, future) }, + Ok(Err(status)) => status, + Err(_) => $crate::ABI_PANIC, + } } #[cfg(all(feature = "node-napi", not(feature = "wasm")))] #[::eqts::napi_derive::napi(js_name = "eqtsHandleInvoke")] pub fn __eqts_napi_handle_invoke(handle: ::eqts::napi::bindgen_prelude::BigInt, method: String, arguments: Vec<::eqts::__private::Value>) -> ::eqts::napi::Result<::eqts::__private::Value> { let (_, handle, lossless) = handle.get_u64(); if !lossless { return Err(::eqts::napi::Error::from_reason("handle out of range")); } - $crate::invoke_handle(handle, &method, arguments).map_err(::eqts::napi::Error::from_reason) + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::invoke_handle(handle, &method, arguments))) { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(::eqts::napi::Error::from_reason(error)), + Err(_) => Err(::eqts::napi::Error::from_reason("eqts panic")), + } } #[cfg(all(feature = "node-napi", not(feature = "wasm")))] #[::eqts::napi_derive::napi(js_name = "eqtsHandleInvokeAsync")] pub fn __eqts_napi_handle_invoke_async(handle: ::eqts::napi::bindgen_prelude::BigInt, method: String, arguments: Vec<::eqts::__private::Value>) -> ::eqts::napi::Result<::eqts::napi::bindgen_prelude::BigInt> { let (_, handle, lossless) = handle.get_u64(); if !lossless { return Err(::eqts::napi::Error::from_reason("handle out of range")); } - $crate::invoke_handle_async(handle, &method, arguments).map(Into::into).map_err(::eqts::napi::Error::from_reason) + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::invoke_handle_async(handle, &method, arguments))) { + Ok(Ok(future)) => Ok(future.into()), + Ok(Err(error)) => Err(::eqts::napi::Error::from_reason(error)), + Err(_) => Err(::eqts::napi::Error::from_reason("eqts panic")), + } } #[cfg(all(feature = "wasm", not(feature = "node-napi")))] #[::eqts::wasm_bindgen::prelude::wasm_bindgen(js_name = "eqtsHandleInvoke")] pub fn __eqts_wasm_handle_invoke(handle: u64, method: String, arguments: ::eqts::wasm_bindgen::JsValue) -> Result<::eqts::wasm_bindgen::JsValue, ::eqts::wasm_bindgen::JsValue> { - let arguments = ::eqts::serde_wasm_bindgen::from_value(arguments).map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()))?; - let value = $crate::invoke_handle(handle, &method, arguments).map_err(|error| ::eqts::js_sys::Error::new(&error))?; - let serializer = ::eqts::serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - ::eqts::serde::Serialize::serialize(&value, &serializer).map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()).into()) + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| -> Result<_, String> { + let arguments = ::eqts::serde_wasm_bindgen::from_value(arguments).map_err(|error| error.to_string())?; + $crate::invoke_handle(handle, &method, arguments) + })) { + Ok(Ok(value)) => { + let serializer = ::eqts::serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + ::eqts::serde::Serialize::serialize(&value, &serializer).map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()).into()) + } + Ok(Err(error)) => Err(::eqts::js_sys::Error::new(&error).into()), + Err(_) => Err(::eqts::js_sys::Error::new("eqts panic").into()), + } } #[cfg(all(feature = "wasm", not(feature = "node-napi")))] #[::eqts::wasm_bindgen::prelude::wasm_bindgen(js_name = "eqtsHandleInvokeAsync")] pub fn __eqts_wasm_handle_invoke_async(handle: u64, method: String, arguments: ::eqts::wasm_bindgen::JsValue) -> Result { - let arguments = ::eqts::serde_wasm_bindgen::from_value(arguments).map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()))?; - $crate::invoke_handle_async(handle, &method, arguments).map_err(|error| ::eqts::js_sys::Error::new(&error).into()) + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| -> Result<_, String> { + let arguments = ::eqts::serde_wasm_bindgen::from_value(arguments).map_err(|error| error.to_string())?; + $crate::invoke_handle_async(handle, &method, arguments) + })) { + Ok(Ok(future)) => Ok(future), + Ok(Err(error)) => Err(::eqts::js_sys::Error::new(&error).into()), + Err(_) => Err(::eqts::js_sys::Error::new("eqts panic").into()), + } } #[cfg(all(feature = "node-napi", not(feature = "wasm")))] @@ -865,9 +947,14 @@ macro_rules! setup { pub fn __eqts_napi_reactive_poll(handle: ::eqts::napi::bindgen_prelude::BigInt) -> ::eqts::napi::Result<::eqts::__private::Value> { let (_, handle, lossless) = handle.get_u64(); if !lossless { return Err(::eqts::napi::Error::from_reason("reactive handle is out of range")); } - let (status, bytes) = $crate::poll_reactive(handle).map_err(|_| ::eqts::napi::Error::from_reason("unknown reactive handle"))?; - let value = bytes.map(|bytes| ::eqts::__private::from_slice(&bytes)).transpose().map_err(|error| ::eqts::napi::Error::from_reason(error.to_string()))?; - Ok(::eqts::serde::Serialize::serialize(&$crate::ReactiveJsPoll { status, value }, ::eqts::serde_json::value::Serializer).expect("JSON value serialization cannot fail")) + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::poll_reactive(handle))) { + Ok(Ok((status, bytes))) => { + let value = bytes.map(|bytes| ::eqts::__private::from_slice(&bytes)).transpose().map_err(|error| ::eqts::napi::Error::from_reason(error.to_string()))?; + Ok(::eqts::serde::Serialize::serialize(&$crate::ReactiveJsPoll { status, value }, ::eqts::serde_json::value::Serializer).expect("JSON value serialization cannot fail")) + } + Ok(Err(_)) => Err(::eqts::napi::Error::from_reason("unknown reactive handle")), + Err(_) => Err(::eqts::napi::Error::from_reason("eqts panic")), + } } #[cfg(all(feature = "wasm", not(feature = "node-napi")))] @@ -883,10 +970,15 @@ macro_rules! setup { #[cfg(all(feature = "wasm", not(feature = "node-napi")))] #[::eqts::wasm_bindgen::prelude::wasm_bindgen(js_name = "eqtsReactivePoll")] pub fn __eqts_wasm_reactive_poll(handle: u64) -> Result<::eqts::wasm_bindgen::JsValue, ::eqts::wasm_bindgen::JsValue> { - let (status, bytes) = $crate::poll_reactive(handle).map_err(|_| ::eqts::js_sys::Error::new("unknown reactive handle"))?; - let value = bytes.map(|bytes| ::eqts::__private::from_slice(&bytes)).transpose().map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()))?; - let serializer = ::eqts::serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - ::eqts::serde::Serialize::serialize(&$crate::ReactiveJsPoll { status, value }, &serializer).map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()).into()) + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| $crate::poll_reactive(handle))) { + Ok(Ok((status, bytes))) => { + let value = bytes.map(|bytes| ::eqts::__private::from_slice(&bytes)).transpose().map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()))?; + let serializer = ::eqts::serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + ::eqts::serde::Serialize::serialize(&$crate::ReactiveJsPoll { status, value }, &serializer).map_err(|error| ::eqts::js_sys::Error::new(&error.to_string()).into()) + } + Ok(Err(_)) => Err(::eqts::js_sys::Error::new("unknown reactive handle").into()), + Err(_) => Err(::eqts::js_sys::Error::new("eqts panic").into()), + } } }; } @@ -901,10 +993,41 @@ mod tests { serde_json::from_slice(metadata_json()).expect("metadata must be valid JSON"); assert_eq!(metadata["schema_version"], 3); assert_eq!(metadata["capabilities"]["owned_values"], true); - assert_eq!(metadata["capabilities"]["async_functions"], true); + assert_eq!(metadata["capabilities"]["async_functions"], false); + assert_eq!(metadata["capabilities"]["objects"], false); + assert_eq!(metadata["capabilities"]["callbacks"], false); + assert_eq!(metadata["capabilities"]["traits"], false); + assert_eq!(metadata["capabilities"]["streams"], false); + assert_eq!(metadata["capabilities"]["iterators"], false); assert_eq!(metadata["functions"], serde_json::json!([])); } + fn dummy_entry() -> ReactiveEntry { + ReactiveEntry { + resource: Arc::new(Mutex::new(Box::new(IdleResource))), + cancelled: false, + callbacks: Arc::new(Mutex::new(VecDeque::new())), + invoke: None, + } + } + + #[test] + fn handle_ids_skip_live_entries_and_do_not_wrap() { + let mut registry = ReactiveRegistry { + next: 1, + entries: HashMap::new(), + }; + registry.entries.insert(1, dummy_entry()); + registry.entries.insert(2, dummy_entry()); + registry.next = 1; + assert_eq!(allocate_handle(&mut registry), 3); + registry.next = u64::MAX; + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + allocate_handle(&mut registry); + })); + assert!(panicked.is_err()); + } + #[test] fn null_output_is_rejected() { // SAFETY: A null pointer is explicitly accepted and rejected before dereference. diff --git a/eq-ts/tests/export.rs b/eq-ts/tests/export.rs index b2fc0d6..1806ca9 100644 --- a/eq-ts/tests/export.rs +++ b/eq-ts/tests/export.rs @@ -33,6 +33,13 @@ fn metadata_is_versioned_and_sorted() { let metadata: serde_json::Value = serde_json::from_slice(eqts::metadata_json()).expect("metadata must be valid JSON"); assert_eq!(metadata["schema_version"], 3); + assert_eq!(metadata["capabilities"]["owned_values"], true); + assert_eq!(metadata["capabilities"]["objects"], false); + assert_eq!(metadata["capabilities"]["async_functions"], false); + assert_eq!(metadata["capabilities"]["callbacks"], false); + assert_eq!(metadata["capabilities"]["traits"], false); + assert_eq!(metadata["capabilities"]["streams"], false); + assert_eq!(metadata["capabilities"]["iterators"], false); assert_eq!(metadata["functions"][0]["name"], "add"); assert_eq!(metadata["functions"][1]["name"], "invert"); } diff --git a/eq-ts/tests/reactive.rs b/eq-ts/tests/reactive.rs index 7f3b446..80bf717 100644 --- a/eq-ts/tests/reactive.rs +++ b/eq-ts/tests/reactive.rs @@ -67,6 +67,43 @@ pub fn range(end: u32) -> Values { Values((0..end).collect()) } +#[eqts::stream(u32)] +#[must_use] +#[expect(clippy::missing_panics_doc)] +pub fn exploding() -> Values { + panic!("ctor boom"); +} + +struct PanicPoll; + +impl eqts::ReactiveResource for PanicPoll { + fn poll(&mut self) -> Result { + panic!("poll boom"); + } +} + +struct PanicMethods; + +impl eqts::EqtsMethods for PanicMethods { + fn invoke( + &mut self, + _method: &str, + _arguments: Vec, + ) -> Result { + panic!("invoke boom"); + } +} + +struct Reenter; + +impl eqts::ReactiveResource for Reenter { + fn poll(&mut self) -> Result { + let nested = eqts::register_reactive(Values(VecDeque::new())); + assert_eq!(eqts::dispose_reactive(nested), eqts::ABI_OK); + Ok(eqts::ReactivePoll::Done) + } +} + #[test] fn reactive_poll_is_demand_driven_and_disposal_is_idempotent() { let handle = eqts::register_reactive(Values([1, 2].into())); @@ -108,7 +145,19 @@ fn reactive_annotations_emit_handles_and_kinds() { .iter() .map(|function| function["kind"]["kind"].as_str().expect("kind string")) .collect::>(); - assert_eq!(kinds, ["async", "callback", "stream", "iterator", "object"]); + assert_eq!( + kinds, + [ + "async", "callback", "stream", "stream", "iterator", "object" + ] + ); + assert_eq!(metadata["capabilities"]["owned_values"], true); + assert_eq!(metadata["capabilities"]["async_functions"], true); + assert_eq!(metadata["capabilities"]["callbacks"], true); + assert_eq!(metadata["capabilities"]["streams"], true); + assert_eq!(metadata["capabilities"]["iterators"], true); + assert_eq!(metadata["capabilities"]["objects"], true); + assert_eq!(metadata["capabilities"]["traits"], false); let input = b"[2]"; // SAFETY: input and output remain valid for the call. assert_eq!( @@ -151,3 +200,43 @@ fn reactive_annotations_emit_handles_and_kinds() { true ); } + +#[test] +fn reactive_abi_catches_panics() { + let mut handle = 0; + // SAFETY: output points to live writable storage. + assert_eq!( + unsafe { eqts_exploding(std::ptr::null(), 0, &raw mut handle) }, + eqts::ABI_PANIC + ); + + let poll_handle = eqts::register_reactive(PanicPoll); + let mut output = eqts::OwnedBuffer::empty(); + // SAFETY: output points to live writable storage. + assert_eq!( + unsafe { eqts_reactive_poll_v1(poll_handle, &raw mut output) }, + eqts::ABI_PANIC + ); + + let object = eqts::register_object(PanicMethods); + let input = br#"{"method":"push","arguments":[]}"#; + let mut invoke_output = eqts::OwnedBuffer::empty(); + // SAFETY: input and output remain valid for the call. + assert_eq!( + unsafe { + eqts_handle_invoke_v1(object, input.as_ptr(), input.len(), &raw mut invoke_output) + }, + eqts::ABI_PANIC + ); +} + +#[test] +fn poll_does_not_hold_registry_lock() { + let handle = eqts::register_reactive(Reenter); + let mut output = eqts::OwnedBuffer::empty(); + // SAFETY: output points to live writable storage. + assert_eq!( + unsafe { eqts_reactive_poll_v1(handle, &raw mut output) }, + eqts::ABI_REACTIVE_DONE + ); +} diff --git a/eqts-macros/src/lib.rs b/eqts-macros/src/lib.rs index de7e5d9..37b2c23 100644 --- a/eqts-macros/src/lib.rs +++ b/eqts-macros/src/lib.rs @@ -249,16 +249,37 @@ fn expand_reactive( #[unsafe(no_mangle)] pub unsafe extern "C" fn #wrapper(input: *const u8, len: usize, output: *mut u64) -> i32 { if output.is_null() || (input.is_null() && len != 0) { return ::eqts::ABI_NULL_OUTPUT; } - let input = if len == 0 { &b"[]"[..] } else { unsafe { std::slice::from_raw_parts(input, len) } }; - let values = match ::eqts::__private::from_slice(input) { Ok(values) => values, Err(_) => return ::eqts::ABI_INVALID_INPUT }; - match #construct(values) { Ok(handle) => unsafe { ::eqts::__private::write_output(output, handle) }, Err(_) => ::eqts::ABI_INVALID_INPUT } + let operation = ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| -> ::std::result::Result { + let input = if len == 0 { &b"[]"[..] } else { unsafe { ::std::slice::from_raw_parts(input, len) } }; + let values = ::eqts::__private::from_slice(input).map_err(|_| ::eqts::ABI_INVALID_INPUT)?; + #construct(values).map_err(|_| ::eqts::ABI_INVALID_INPUT) + })); + match operation { + Ok(Ok(handle)) => unsafe { ::eqts::__private::write_output(output, handle) }, + Ok(Err(status)) => status, + Err(_) => ::eqts::ABI_PANIC, + } } #[cfg(all(feature = "node-napi", not(feature = "wasm")))] #[::eqts::napi_derive::napi(js_name = #js_name)] - fn #napi(#(#napi_inputs),*) -> ::eqts::napi::Result<::eqts::napi::bindgen_prelude::BigInt> { #construct(vec![#(#napi_values),*]).map(Into::into).map_err(::eqts::napi::Error::from_reason) } + fn #napi(#(#napi_inputs),*) -> ::eqts::napi::Result<::eqts::napi::bindgen_prelude::BigInt> { + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| #construct(vec![#(#napi_values),*]))) { + Ok(Ok(handle)) => Ok(handle.into()), + Ok(Err(error)) => Err(::eqts::napi::Error::from_reason(error)), + Err(_) => Err(::eqts::napi::Error::from_reason("eqts panic")), + } + } #[cfg(all(feature = "wasm", not(feature = "node-napi")))] #[::eqts::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name)] - pub fn #wasm(#(#wasm_inputs),*) -> Result { let values = (|| -> Result<_, String> { Ok(vec![#(#wasm_values),*]) })().map_err(|error| ::eqts::js_sys::Error::new(&error))?; #construct(values).map_err(|error| ::eqts::js_sys::Error::new(&error).into()) } + pub fn #wasm(#(#wasm_inputs),*) -> Result { + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| -> Result<_, String> { + Ok(#construct(vec![#(#wasm_values),*])?) + })) { + Ok(Ok(handle)) => Ok(handle), + Ok(Err(error)) => Err(::eqts::js_sys::Error::new(&error).into()), + Err(_) => Err(::eqts::js_sys::Error::new("eqts panic").into()), + } + } ::eqts::inventory::submit! { ::eqts::FunctionRegistration { describe: || ::eqts::Function { module: module_path!(), name: stringify!(#name), symbol: stringify!(#wrapper), abi: ::eqts::Abi::Json, parameters: vec![#(#parameters),*], result: ::eqts::Type::Scalar { scalar: ::eqts::Scalar::U64 }, kind: #kind_value @@ -362,7 +383,6 @@ fn expand_scalar( .iter() .map(|argument| plain_ident(argument).expect("validated parameter")) .collect::>(); - let direct_output = &function.sig.output; let rust_result_ty = match &function.sig.output { ReturnType::Default => quote!(()), ReturnType::Type(_, ty) => quote!(#ty), @@ -372,10 +392,10 @@ fn expand_scalar( } else { quote!(-> ::eqts::napi::Result<#rust_result_ty>) }; - let napi_call = if result.is_64_bit() { - quote!(Ok(#name(#(#direct_calls),*).into())) + let napi_ok = if result.is_64_bit() { + quote!(Ok(value.into())) } else { - quote!(Ok(#name(#(#direct_calls),*))) + quote!(Ok(value)) }; let (output, invoke) = if matches!(result, TypeKind::Void) { ( @@ -404,11 +424,22 @@ fn expand_scalar( "e! { #[cfg(all(feature = "node-napi", not(feature = "wasm")))] #[::eqts::napi_derive::napi(js_name = #js_name)] - fn #napi_bridge(#(#napi_inputs),*) #napi_output { #(#napi_conversions)* #napi_call } + fn #napi_bridge(#(#napi_inputs),*) #napi_output { + #(#napi_conversions)* + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| #name(#(#direct_calls),*))) { + Ok(value) => #napi_ok, + Err(_) => Err(::eqts::napi::Error::from_reason("eqts panic")), + } + } #[cfg(all(feature = "wasm", not(feature = "node-napi")))] #[::eqts::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name)] - pub fn #wasm_bridge(#(#direct_inputs),*) #direct_output { #name(#(#direct_calls),*) } + pub fn #wasm_bridge(#(#direct_inputs),*) -> ::std::result::Result<#rust_result_ty, ::eqts::wasm_bindgen::JsValue> { + match ::eqts::__private::catch_unwind(::eqts::__private::AssertUnwindSafe(|| #name(#(#direct_calls),*))) { + Ok(value) => Ok(value), + Err(_) => Err(::eqts::js_sys::Error::new("eqts panic").into()), + } + } #[unsafe(no_mangle)] #[doc = "# Safety"]