#![warn(missing_docs)]
#![warn(rustdoc::broken_intra_doc_links)]
pub use node_app_api::types::{
AppEvent, AppRequest, AppResponse, Capabilities, CapabilityExample, CapabilityRequest,
CapabilityResponse, NodeAppInfo, ProvidedCapability,
};
pub use node_app_api::context::NodeAppContext;
pub use node_app_api::ffi::{FfiResult, NodeAppMetadata, NodeAppVTable};
pub use node_app_api::API_VERSION;
use std::cell::RefCell;
use std::ffi::CString;
use std::sync::atomic::{AtomicPtr, Ordering};
use std::sync::{Condvar, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
pub const MAX_CAPABILITY_RESPONSE_SIZE: usize = 16 * 1024 * 1024;
#[doc(hidden)]
#[derive(Clone)]
pub struct CurrentTrace {
pub trace_id: String,
pub span_id: String,
pub depth: u8,
}
thread_local! {
#[doc(hidden)]
pub static CURRENT_TRACE: RefCell<Option<CurrentTrace>> = const { RefCell::new(None) };
#[doc(hidden)]
pub static CURRENT_INVOCATION_CONTEXT: RefCell<Option<String>> = const { RefCell::new(None) };
}
#[doc(hidden)]
pub struct CurrentInvocationContextGuard(Option<String>);
impl CurrentInvocationContextGuard {
#[doc(hidden)]
pub fn enter(invocation_context_id: Option<String>) -> Self {
let previous = CURRENT_INVOCATION_CONTEXT.with(|current| {
std::mem::replace(&mut *current.borrow_mut(), invocation_context_id)
});
Self(previous)
}
}
impl Drop for CurrentInvocationContextGuard {
fn drop(&mut self) {
let previous = self.0.take();
CURRENT_INVOCATION_CONTEXT.with(|current| {
*current.borrow_mut() = previous;
});
}
}
static APP_CONTEXT: AtomicPtr<NodeAppContext> = AtomicPtr::new(std::ptr::null_mut());
pub const ERROR_SHUTTING_DOWN: i32 = -11;
pub const SHUTDOWN_DRAIN_WARN_AFTER: Duration = Duration::from_secs(2);
#[doc(hidden)]
pub struct CallGate {
state: Mutex<CallGateState>,
drained: Condvar,
}
struct CallGateState {
closed: bool,
in_flight: usize,
}
impl CallGate {
#[doc(hidden)]
pub const fn new() -> Self {
Self {
state: Mutex::new(CallGateState {
closed: false,
in_flight: 0,
}),
drained: Condvar::new(),
}
}
fn lock(&self) -> MutexGuard<'_, CallGateState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
#[doc(hidden)]
pub fn enter(&self) -> Option<CallPermit<'_>> {
let mut state = self.lock();
if state.closed {
return None;
}
state.in_flight += 1;
Some(CallPermit(self))
}
#[doc(hidden)]
pub fn open(&self) {
self.lock().closed = false;
}
#[doc(hidden)]
pub fn close(&self) {
self.lock().closed = true;
}
#[doc(hidden)]
pub fn wait_drained(&self, timeout: Option<Duration>) -> usize {
let state = self.lock();
let state = match timeout {
Some(timeout) => {
self.drained
.wait_timeout_while(state, timeout, |state| state.in_flight > 0)
.unwrap_or_else(PoisonError::into_inner)
.0
}
None => self
.drained
.wait_while(state, |state| state.in_flight > 0)
.unwrap_or_else(PoisonError::into_inner),
};
state.in_flight
}
}
impl Default for CallGate {
fn default() -> Self {
Self::new()
}
}
#[doc(hidden)]
pub struct CallPermit<'a>(&'a CallGate);
impl Drop for CallPermit<'_> {
fn drop(&mut self) {
let mut state = self.0.lock();
state.in_flight -= 1;
if state.in_flight == 0 {
self.0.drained.notify_all();
}
}
}
pub mod llm;
pub mod log_level {
pub const TRACE: u32 = 0;
pub const DEBUG: u32 = 1;
pub const INFO: u32 = 2;
pub const WARN: u32 = 3;
pub const ERROR: u32 = 4;
}
pub fn log(level: u32, message: &str) {
let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
if ctx_ptr.is_null() {
return;
}
let c_message = match CString::new(message) {
Ok(s) => s,
Err(_) => return, };
unsafe {
let ctx = &*ctx_ptr;
(ctx.host_log)(ctx.host_data, level, c_message.as_ptr());
}
}
pub fn invoke_capability(request: &CapabilityRequest) -> Result<CapabilityResponse, NodeAppError> {
let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
if ctx_ptr.is_null() {
return Err(NodeAppError::CapabilityError(
"Host context not available".into(),
));
}
let active_invocation_context =
CURRENT_INVOCATION_CONTEXT.with(|current| current.borrow().clone());
let active_trace = if request.trace_id.is_none() {
CURRENT_TRACE.with(|current| current.borrow().clone())
} else {
None
};
let effective_request: std::borrow::Cow<CapabilityRequest> =
if active_trace.is_some() || active_invocation_context.is_some() {
let mut injected = request.clone();
if let Some(trace) = active_trace {
injected.trace_id = Some(trace.trace_id);
injected.span_id = Some(trace.span_id);
injected.parent_span_id = None;
injected.trace_depth = Some(trace.depth);
}
if let Some(context_id) = active_invocation_context {
injected.invocation_context_id = Some(context_id);
}
std::borrow::Cow::Owned(injected)
} else {
std::borrow::Cow::Borrowed(request)
};
let request_json = serde_json::to_vec(effective_request.as_ref())?;
unsafe {
let ctx = &*ctx_ptr;
if ctx.host_invoke_capability as usize == 0 {
return Err(NodeAppError::CapabilityError(
"host_invoke_capability callback not available".into(),
));
}
let result = (ctx.host_invoke_capability)(
ctx.host_data,
request_json.as_ptr(),
request_json.len(),
);
if result.success && !result.data.is_null() && result.data_len > 0 {
let response_slice = std::slice::from_raw_parts(result.data, result.data_len);
let response: CapabilityResponse = serde_json::from_slice(response_slice)
.map_err(|e| NodeAppError::CapabilityError(format!("Response deserialization error: {}", e)))?;
Ok(response)
} else if !result.success {
Err(NodeAppError::CapabilityError(format!(
"Host capability invocation failed with error code {}",
result.error_code
)))
} else {
Err(NodeAppError::CapabilityError(
"Empty response from host".into(),
))
}
}
}
pub const MAX_EVENT_NAME_LEN: usize = 256;
pub const MAX_EVENT_DATA_LEN: usize = 64 * 1024;
pub fn publish_event(name: &str, data: &serde_json::Value) -> Result<(), NodeAppError> {
let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
if ctx_ptr.is_null() {
return Err(NodeAppError::EventFailed(
"Host context not available".into(),
));
}
let name_bytes = name.as_bytes();
if name_bytes.len() > MAX_EVENT_NAME_LEN {
return Err(NodeAppError::EventFailed(format!(
"Event name exceeds {} byte limit (got {})",
MAX_EVENT_NAME_LEN,
name_bytes.len()
)));
}
let data_json = serde_json::to_vec(data)?;
if data_json.len() > MAX_EVENT_DATA_LEN {
return Err(NodeAppError::EventFailed(format!(
"Event data exceeds {} byte limit (got {})",
MAX_EVENT_DATA_LEN,
data_json.len()
)));
}
unsafe {
let ctx = &*ctx_ptr;
if ctx.host_publish_event as usize == 0 {
return Err(NodeAppError::EventFailed(
"host_publish_event callback not available".into(),
));
}
let result = (ctx.host_publish_event)(
ctx.host_data,
name_bytes.as_ptr(),
name_bytes.len(),
data_json.as_ptr(),
data_json.len(),
);
if result == 0 {
Ok(())
} else {
Err(NodeAppError::EventFailed(format!(
"host_publish_event returned error code {}",
result
)))
}
}
}
#[macro_export]
macro_rules! log_trace {
($($arg:tt)*) => {
$crate::log($crate::log_level::TRACE, &format!($($arg)*))
};
}
#[macro_export]
macro_rules! log_debug {
($($arg:tt)*) => {
$crate::log($crate::log_level::DEBUG, &format!($($arg)*))
};
}
#[macro_export]
macro_rules! log_info {
($($arg:tt)*) => {
$crate::log($crate::log_level::INFO, &format!($($arg)*))
};
}
#[macro_export]
macro_rules! log_warn {
($($arg:tt)*) => {
$crate::log($crate::log_level::WARN, &format!($($arg)*))
};
}
#[macro_export]
macro_rules! log_error {
($($arg:tt)*) => {
$crate::log($crate::log_level::ERROR, &format!($($arg)*))
};
}
#[doc(hidden)]
pub fn __store_context(ctx: *const NodeAppContext) {
APP_CONTEXT.store(ctx as *mut NodeAppContext, Ordering::Release);
}
pub fn get_config(key: &str) -> Option<String> {
let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
if ctx_ptr.is_null() {
return None;
}
let c_key = CString::new(key).ok()?;
unsafe {
let ctx = &*ctx_ptr;
let result = (ctx.host_get_config)(ctx.host_data, c_key.as_ptr());
if result.is_null() {
return None;
}
Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
}
}
pub fn set_storage(key: &str, value: &str) {
let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
if ctx_ptr.is_null() {
return;
}
let c_key = match CString::new(key) {
Ok(s) => s,
Err(_) => return,
};
let c_value = match CString::new(value) {
Ok(s) => s,
Err(_) => return,
};
unsafe {
let ctx = &*ctx_ptr;
(ctx.host_set_storage)(ctx.host_data, c_key.as_ptr(), c_value.as_ptr());
}
}
pub fn get_storage(key: &str) -> Option<String> {
let ctx_ptr = APP_CONTEXT.load(Ordering::Acquire);
if ctx_ptr.is_null() {
return None;
}
let c_key = CString::new(key).ok()?;
unsafe {
let ctx = &*ctx_ptr;
let result = (ctx.host_get_storage)(ctx.host_data, c_key.as_ptr());
if result.is_null() {
return None;
}
Some(std::ffi::CStr::from_ptr(result).to_string_lossy().into_owned())
}
}
#[derive(Debug, thiserror::Error)]
pub enum NodeAppError {
#[error("Initialization failed: {0}")]
InitFailed(String),
#[error("Request handling failed: {0}")]
RequestFailed(String),
#[error("Event handling failed: {0}")]
EventFailed(String),
#[error("Shutdown failed: {0}")]
ShutdownFailed(String),
#[error("Serialization error: {0}")]
SerializationError(#[from] serde_json::Error),
#[error("Capability error: {0}")]
CapabilityError(String),
}
pub trait NodeApp: Default + Send + Sync + 'static {
fn metadata() -> NodeAppInfo;
fn init(&mut self, _ctx: Option<&NodeAppContext>) -> Result<(), NodeAppError> {
Ok(())
}
fn begin_shutdown(&self) {}
fn shutdown(&mut self) -> Result<(), NodeAppError> {
Ok(())
}
fn handle_request(&self, _request: AppRequest) -> Result<AppResponse, NodeAppError> {
Ok(AppResponse {
status: 501,
headers: Default::default(),
body: serde_json::json!({"error": "Not implemented"}),
})
}
fn handle_event(&self, _event: AppEvent) -> Result<(), NodeAppError> {
Ok(())
}
fn provided_capabilities() -> Vec<ProvidedCapability> {
Vec::new()
}
fn handle_capability(
&self,
_request: CapabilityRequest,
) -> Result<CapabilityResponse, NodeAppError> {
Err(NodeAppError::CapabilityError(
"Capability handling not implemented".into(),
))
}
}
#[macro_export]
macro_rules! declare_node_app {
($app_type:ty) => {
static APP_INSTANCE: std::sync::OnceLock<std::sync::RwLock<$app_type>> =
std::sync::OnceLock::new();
static CALL_GATE: $crate::CallGate = $crate::CallGate::new();
static VTABLE: std::sync::OnceLock<$crate::NodeAppVTable> =
std::sync::OnceLock::new();
static META_NAME: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
static META_VERSION: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
static META_AUTHOR: std::sync::OnceLock<std::ffi::CString> = std::sync::OnceLock::new();
static META_DESCRIPTION: std::sync::OnceLock<std::ffi::CString> =
std::sync::OnceLock::new();
unsafe extern "C" fn __node_app_init(
ctx: *const std::os::raw::c_void,
) -> $crate::FfiResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let ctx_opt = if ctx.is_null() {
None
} else {
let ctx_typed = ctx as *const $crate::NodeAppContext;
$crate::__store_context(ctx_typed);
Some(unsafe { &*ctx_typed })
};
let app = APP_INSTANCE.get_or_init(|| {
std::sync::RwLock::new(<$app_type>::default())
});
let mut guard = match app.write() {
Ok(g) => g,
Err(e) => {
eprintln!("[node-app] rwlock poisoned in init: {}", e);
return $crate::FfiResult::error(-10);
}
};
CALL_GATE.open();
match guard.init(ctx_opt) {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => {
let msg = format!("init error: {}", e);
$crate::log($crate::log_level::ERROR, &msg);
eprintln!("[node-app] {}", msg);
$crate::FfiResult::error(-1)
}
}
})) {
Ok(result) => result,
Err(_) => {
eprintln!("[node-app] panic in init");
$crate::FfiResult::error(-99)
}
}
}
unsafe extern "C" fn __node_app_shutdown() -> $crate::FfiResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if let Some(app) = APP_INSTANCE.get() {
CALL_GATE.close();
let hook = match app.read() {
Ok(app) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
<$app_type as $crate::NodeApp>::begin_shutdown(&app)
})),
Err(e) => {
eprintln!("[node-app] rwlock poisoned in shutdown: {}", e);
return $crate::FfiResult::error(-10);
}
};
if hook.is_err() {
let msg = "panic in begin_shutdown; still waiting for in-flight calls";
$crate::log($crate::log_level::ERROR, msg);
eprintln!("[node-app] {}", msg);
}
let in_flight =
CALL_GATE.wait_drained(Some($crate::SHUTDOWN_DRAIN_WARN_AFTER));
if in_flight > 0 {
let msg = format!(
"shutdown: {} call(s) still in flight after {} ms; waiting for them to return",
in_flight,
$crate::SHUTDOWN_DRAIN_WARN_AFTER.as_millis()
);
$crate::log($crate::log_level::WARN, &msg);
eprintln!("[node-app] {}", msg);
CALL_GATE.wait_drained(None);
}
let mut guard = match app.write() {
Ok(g) => g,
Err(e) => {
eprintln!("[node-app] rwlock poisoned in shutdown: {}", e);
return $crate::FfiResult::error(-10);
}
};
match guard.shutdown() {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => {
let msg = format!("shutdown error: {}", e);
$crate::log($crate::log_level::ERROR, &msg);
eprintln!("[node-app] {}", msg);
$crate::FfiResult::error(-1)
}
}
} else {
$crate::FfiResult::ok()
}
})) {
Ok(result) => result,
Err(_) => {
eprintln!("[node-app] panic in shutdown");
$crate::FfiResult::error(-99)
}
}
}
unsafe extern "C" fn __node_app_handle_request(
request_json: *const u8,
request_len: usize,
) -> $crate::FfiResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let json_slice =
unsafe { std::slice::from_raw_parts(request_json, request_len) };
let request: $crate::AppRequest = match serde_json::from_slice(json_slice) {
Ok(r) => r,
Err(e) => {
eprintln!("[node-app] request deserialization error: {}", e);
return $crate::FfiResult::error(-2);
}
};
let app = match APP_INSTANCE.get() {
Some(a) => a,
None => return $crate::FfiResult::error(-3),
};
let _call = match CALL_GATE.enter() {
Some(call) => call,
None => return $crate::FfiResult::error($crate::ERROR_SHUTTING_DOWN),
};
let guard = match app.read() {
Ok(g) => g,
Err(e) => {
eprintln!("[node-app] rwlock poisoned in handle_request: {}", e);
return $crate::FfiResult::error(-10);
}
};
match guard.handle_request(request) {
Ok(response) => match serde_json::to_vec(&response) {
Ok(bytes) => {
let len = bytes.len();
let boxed = bytes.into_boxed_slice();
let ptr = Box::into_raw(boxed) as *mut u8;
$crate::FfiResult {
success: true,
error_code: 0,
data: ptr,
data_len: len,
}
}
Err(e) => {
eprintln!("[node-app] response serialization error: {}", e);
$crate::FfiResult::error(-4)
}
},
Err(e) => {
eprintln!("[node-app] handle_request error: {}", e);
$crate::FfiResult::error(-5)
}
}
})) {
Ok(result) => result,
Err(_) => {
eprintln!("[node-app] panic in handle_request");
$crate::FfiResult::error(-99)
}
}
}
unsafe extern "C" fn __node_app_handle_event(
event_json: *const u8,
event_len: usize,
) -> $crate::FfiResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let json_slice =
unsafe { std::slice::from_raw_parts(event_json, event_len) };
let event: $crate::AppEvent = match serde_json::from_slice(json_slice) {
Ok(e) => e,
Err(e) => {
eprintln!("[node-app] event deserialization error: {}", e);
return $crate::FfiResult::error(-2);
}
};
let app = match APP_INSTANCE.get() {
Some(a) => a,
None => return $crate::FfiResult::error(-3),
};
let _call = match CALL_GATE.enter() {
Some(call) => call,
None => return $crate::FfiResult::error($crate::ERROR_SHUTTING_DOWN),
};
let guard = match app.read() {
Ok(g) => g,
Err(e) => {
eprintln!("[node-app] rwlock poisoned in handle_event: {}", e);
return $crate::FfiResult::error(-10);
}
};
match guard.handle_event(event) {
Ok(()) => $crate::FfiResult::ok(),
Err(e) => {
eprintln!("[node-app] handle_event error: {}", e);
$crate::FfiResult::error(-5)
}
}
})) {
Ok(result) => result,
Err(_) => {
eprintln!("[node-app] panic in handle_event");
$crate::FfiResult::error(-99)
}
}
}
unsafe extern "C" fn __node_app_handle_capability(
request_json: *const u8,
request_len: usize,
) -> $crate::FfiResult {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let json_slice =
unsafe { std::slice::from_raw_parts(request_json, request_len) };
let request: $crate::CapabilityRequest = match serde_json::from_slice(json_slice) {
Ok(r) => r,
Err(e) => {
eprintln!("[node-app] capability request deserialization error: {}", e);
return $crate::FfiResult::error(-2);
}
};
let app = match APP_INSTANCE.get() {
Some(a) => a,
None => return $crate::FfiResult::error(-3),
};
let _call = match CALL_GATE.enter() {
Some(call) => call,
None => return $crate::FfiResult::error($crate::ERROR_SHUTTING_DOWN),
};
let guard = match app.read() {
Ok(g) => g,
Err(e) => {
eprintln!("[node-app] rwlock poisoned in handle_capability: {}", e);
return $crate::FfiResult::error(-10);
}
};
$crate::CURRENT_TRACE.with(|tl| {
*tl.borrow_mut() = if let Some(ref trace_id) = request.trace_id {
Some($crate::CurrentTrace {
trace_id: trace_id.clone(),
span_id: request.span_id.clone().unwrap_or_default(),
depth: request.trace_depth.unwrap_or(0),
})
} else {
None
};
});
let _invocation_context_guard =
$crate::CurrentInvocationContextGuard::enter(
request.invocation_context_id.clone(),
);
let cap_result = guard.handle_capability(request);
$crate::CURRENT_TRACE.with(|tl| {
*tl.borrow_mut() = None;
});
match cap_result {
Ok(response) => match serde_json::to_vec(&response) {
Ok(bytes) => {
if bytes.len() > $crate::MAX_CAPABILITY_RESPONSE_SIZE {
eprintln!(
"[node-app] capability response exceeds 16MB limit ({} bytes)",
bytes.len()
);
return $crate::FfiResult::error(-6);
}
let len = bytes.len();
let boxed = bytes.into_boxed_slice();
let ptr = Box::into_raw(boxed) as *mut u8;
$crate::FfiResult {
success: true,
error_code: 0,
data: ptr,
data_len: len,
}
}
Err(e) => {
eprintln!("[node-app] capability response serialization error: {}", e);
$crate::FfiResult::error(-4)
}
},
Err(e) => {
eprintln!("[node-app] handle_capability error: {}", e);
$crate::FfiResult::error(-5)
}
}
})) {
Ok(result) => result,
Err(_) => {
eprintln!("[node-app] panic in handle_capability");
$crate::FfiResult::error(-99)
}
}
}
unsafe extern "C" fn __node_app_free(ptr: *mut u8, len: usize) {
if !ptr.is_null() && len > 0 {
let _ = unsafe { Box::from_raw(std::slice::from_raw_parts_mut(ptr, len)) };
}
}
#[no_mangle]
pub unsafe extern "C" fn _node_app_entry(
_ctx: *const std::os::raw::c_void,
) -> *const $crate::NodeAppVTable {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let info = <$app_type as $crate::NodeApp>::metadata();
let caps = info.capability_flags();
let name = META_NAME
.get_or_init(|| std::ffi::CString::new(info.name).unwrap_or_default());
let version = META_VERSION
.get_or_init(|| std::ffi::CString::new(info.version).unwrap_or_default());
let author = META_AUTHOR
.get_or_init(|| std::ffi::CString::new(info.author).unwrap_or_default());
let description = META_DESCRIPTION
.get_or_init(|| std::ffi::CString::new(info.description).unwrap_or_default());
let metadata = $crate::NodeAppMetadata {
api_version: $crate::API_VERSION,
name: name.as_ptr(),
version: version.as_ptr(),
author: author.as_ptr(),
description: description.as_ptr(),
capabilities: caps.bits(),
};
VTABLE.get_or_init(|| $crate::NodeAppVTable {
metadata,
init: __node_app_init,
shutdown: __node_app_shutdown,
handle_request: __node_app_handle_request,
handle_event: __node_app_handle_event,
handle_capability: __node_app_handle_capability,
free: __node_app_free,
}) as *const $crate::NodeAppVTable
})) {
Ok(ptr) => ptr,
Err(_) => {
eprintln!("[node-app] panic in _node_app_entry");
std::ptr::null()
}
}
}
};
}
#[cfg(test)]
mod invocation_context_tests {
use std::ffi::{c_char, c_void};
use std::sync::Mutex;
use super::*;
pub(super) static TEST_LOCK: Mutex<()> = Mutex::new(());
unsafe extern "C" fn noop_log(_: *const c_void, _: u32, _: *const c_char) {}
unsafe extern "C" fn no_config(_: *const c_void, _: *const c_char) -> *const c_char {
std::ptr::null()
}
unsafe extern "C" fn noop_storage(_: *const c_void, _: *const c_char, _: *const c_char) {}
unsafe extern "C" fn no_storage(_: *const c_void, _: *const c_char) -> *const c_char {
std::ptr::null()
}
unsafe extern "C" fn noop_publish(
_: *const c_void,
_: *const u8,
_: usize,
_: *const u8,
_: usize,
) -> i32 {
0
}
unsafe extern "C" fn capture_invoke(
host_data: *const c_void,
request_json: *const u8,
request_len: usize,
) -> FfiResult {
let requests = &*(host_data as *const Mutex<Vec<CapabilityRequest>>);
let bytes = std::slice::from_raw_parts(request_json, request_len);
requests
.lock()
.unwrap()
.push(serde_json::from_slice(bytes).unwrap());
let response = CapabilityResponse {
id: "nested-1".into(),
success: true,
payload: serde_json::json!({"ok": true}),
};
let bytes = serde_json::to_vec(&response).unwrap().into_boxed_slice();
let len = bytes.len();
let data = Box::into_raw(bytes) as *mut u8;
FfiResult {
success: true,
error_code: 0,
data,
data_len: len,
}
}
#[test]
fn nested_invocation_propagates_opaque_context() {
let _serial = TEST_LOCK.lock().unwrap();
let captured = Mutex::new(Vec::<CapabilityRequest>::new());
let context = NodeAppContext {
host_data: &captured as *const _ as *const c_void,
host_log: noop_log,
host_get_config: no_config,
host_set_storage: noop_storage,
host_get_storage: no_storage,
host_invoke_capability: capture_invoke,
host_publish_event: noop_publish,
};
__store_context(&context);
CURRENT_TRACE.with(|current| {
*current.borrow_mut() = Some(CurrentTrace {
trace_id: "trace-1".into(),
span_id: "span-1".into(),
depth: 4,
});
});
let _invocation =
CurrentInvocationContextGuard::enter(Some("opaque-context-1".into()));
invoke_capability(&CapabilityRequest {
id: "nested-1".into(),
capability: "example.nested".into(),
payload: serde_json::json!({}),
..Default::default()
})
.unwrap();
let requests = captured.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0].invocation_context_id.as_deref(),
Some("opaque-context-1")
);
assert_eq!(requests[0].trace_id.as_deref(), Some("trace-1"));
assert_eq!(requests[0].span_id.as_deref(), Some("span-1"));
drop(requests);
CURRENT_TRACE.with(|current| *current.borrow_mut() = None);
__store_context(std::ptr::null());
}
}
#[cfg(test)]
mod shutdown_gate_tests {
use std::sync::atomic::AtomicBool;
use std::sync::{Condvar, Mutex, PoisonError};
use std::time::Duration;
use super::*;
struct Signal {
set: Mutex<bool>,
changed: Condvar,
}
impl Signal {
const fn new() -> Self {
Self {
set: Mutex::new(false),
changed: Condvar::new(),
}
}
fn set(&self) {
*self.set.lock().unwrap() = true;
self.changed.notify_all();
}
fn reset(&self) {
*self.set.lock().unwrap() = false;
}
fn is_set(&self) -> bool {
*self.set.lock().unwrap()
}
fn wait(&self, timeout: Duration) -> bool {
let set = self.set.lock().unwrap();
*self
.changed
.wait_timeout_while(set, timeout, |set| !*set)
.unwrap()
.0
}
}
static CALL_ENTERED: Signal = Signal::new();
static RELEASE_CALL: Signal = Signal::new();
static HOOK_RAN: Signal = Signal::new();
static SHUTDOWN_RAN: Signal = Signal::new();
static PANIC_IN_HOOK: AtomicBool = AtomicBool::new(false);
#[derive(Default)]
struct SlowApp;
impl NodeApp for SlowApp {
fn metadata() -> NodeAppInfo {
NodeAppInfo {
name: "slow".into(),
version: "0.0.0".into(),
author: "test".into(),
description: "holds a capability call open".into(),
capabilities: Vec::new(),
}
}
fn begin_shutdown(&self) {
HOOK_RAN.set();
if PANIC_IN_HOOK.load(Ordering::SeqCst) {
panic!("begin_shutdown test panic");
}
}
fn shutdown(&mut self) -> Result<(), NodeAppError> {
SHUTDOWN_RAN.set();
Ok(())
}
fn handle_capability(
&self,
request: CapabilityRequest,
) -> Result<CapabilityResponse, NodeAppError> {
CALL_ENTERED.set();
if !RELEASE_CALL.wait(Duration::from_secs(10)) {
return Err(NodeAppError::CapabilityError("never released".into()));
}
Ok(CapabilityResponse {
id: request.id,
success: true,
payload: serde_json::json!({}),
})
}
}
crate::declare_node_app!(SlowApp);
fn code(result: FfiResult) -> i32 {
if !result.success {
return result.error_code;
}
if !result.data.is_null() {
unsafe { __node_app_free(result.data, result.data_len) };
}
0
}
fn call() -> i32 {
let request = serde_json::to_vec(&CapabilityRequest {
id: "call-1".into(),
capability: "slow.wait".into(),
payload: serde_json::json!({}),
..Default::default()
})
.unwrap();
code(unsafe { __node_app_handle_capability(request.as_ptr(), request.len()) })
}
fn request() -> i32 {
let request = serde_json::to_vec(&AppRequest {
id: "request-1".into(),
method: "GET".into(),
path: "/".into(),
headers: Default::default(),
body: serde_json::Value::Null,
caller: None,
})
.unwrap();
code(unsafe { __node_app_handle_request(request.as_ptr(), request.len()) })
}
fn event() -> i32 {
let event = serde_json::to_vec(&AppEvent {
name: "slow.tick".into(),
data: serde_json::Value::Null,
})
.unwrap();
code(unsafe { __node_app_handle_event(event.as_ptr(), event.len()) })
}
fn stop_while_a_call_is_in_flight(panic_in_hook: bool) {
for signal in [&CALL_ENTERED, &RELEASE_CALL, &HOOK_RAN, &SHUTDOWN_RAN] {
signal.reset();
}
PANIC_IN_HOOK.store(panic_in_hook, Ordering::SeqCst);
assert!(unsafe { __node_app_init(std::ptr::null()) }.success);
let in_flight = std::thread::spawn(call);
assert!(
CALL_ENTERED.wait(Duration::from_secs(5)),
"call never started"
);
let shutdown = std::thread::spawn(|| unsafe { __node_app_shutdown() }.success);
assert!(
HOOK_RAN.wait(Duration::from_secs(1)),
"begin_shutdown waited behind the in-flight call"
);
assert_eq!(call(), ERROR_SHUTTING_DOWN, "capability call not refused");
assert_eq!(request(), ERROR_SHUTTING_DOWN, "request not refused");
assert_eq!(event(), ERROR_SHUTTING_DOWN, "event not refused");
assert!(
!SHUTDOWN_RAN.is_set(),
"shutdown(&mut self) ran while a call was in flight"
);
RELEASE_CALL.set();
assert_eq!(in_flight.join().unwrap(), 0, "in-flight call failed");
assert!(shutdown.join().unwrap(), "shutdown entry failed");
assert!(SHUTDOWN_RAN.is_set());
}
#[test]
fn shutdown_hook_runs_while_a_call_is_in_flight() {
let _serial = super::invocation_context_tests::TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
stop_while_a_call_is_in_flight(false);
assert!(unsafe { __node_app_init(std::ptr::null()) }.success);
assert_eq!(call(), 0, "capability call refused after init");
assert_eq!(request(), 0, "request refused after init");
assert_eq!(event(), 0, "event refused after init");
}
#[test]
fn panicking_shutdown_hook_still_waits_for_in_flight_calls() {
let _serial = super::invocation_context_tests::TEST_LOCK
.lock()
.unwrap_or_else(PoisonError::into_inner);
stop_while_a_call_is_in_flight(true);
}
#[test]
fn call_gate_counts_each_call_until_its_permit_drops() {
let gate = CallGate::new();
let first = gate.enter().expect("open gate admits");
let second = gate.enter().expect("open gate admits");
gate.close();
assert!(gate.enter().is_none(), "closed gate admitted a call");
assert_eq!(gate.wait_drained(Some(Duration::from_millis(10))), 2);
drop(first);
assert_eq!(gate.wait_drained(Some(Duration::from_millis(10))), 1);
drop(second);
assert_eq!(gate.wait_drained(None), 0);
gate.open();
assert!(gate.enter().is_some(), "reopened gate refused a call");
}
}