use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use rpi_plugin_sdk::{RuntimeActionId, StbString, StbStringRef};
use tokio::runtime::Handle;
#[async_trait::async_trait]
pub trait RuntimeActionHost: Send + Sync {
async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn send_user_message(&self, args: serde_json::Value)
-> Result<serde_json::Value, String>;
async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn get_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn get_thinking_level(
&self,
args: serde_json::Value,
) -> Result<serde_json::Value, String>;
async fn set_thinking_level(
&self,
args: serde_json::Value,
) -> Result<serde_json::Value, String>;
async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn get_system_prompt(&self, args: serde_json::Value)
-> Result<serde_json::Value, String>;
async fn new_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn fork(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
async fn reload(&self, args: serde_json::Value) -> Result<serde_json::Value, String>;
}
pub struct ActionBridge {
pub(crate) runtime: Handle,
pub(crate) host: Arc<dyn RuntimeActionHost>,
pub(crate) reload:
Option<Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>>,
active: Arc<AtomicBool>,
}
impl ActionBridge {
pub fn new(runtime: Handle, host: Arc<dyn RuntimeActionHost>) -> Arc<Self> {
Arc::new(Self {
runtime,
host,
reload: None,
active: Arc::new(AtomicBool::new(true)),
})
}
pub fn with_reload(
runtime: Handle,
host: Arc<dyn RuntimeActionHost>,
reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync>,
) -> Arc<Self> {
Arc::new(Self {
runtime,
host,
reload: Some(reload),
active: Arc::new(AtomicBool::new(true)),
})
}
pub fn invalidate(&self) {
self.active.store(false, Ordering::SeqCst);
}
pub fn is_active(&self) -> bool {
self.active.load(Ordering::SeqCst)
}
pub fn clone_host(&self) -> Arc<dyn RuntimeActionHost> {
Arc::clone(&self.host)
}
}
#[derive(Clone)]
pub struct ReloadMailbox {
tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<()>>>>,
}
impl Default for ReloadMailbox {
fn default() -> Self {
Self {
tx: Arc::new(std::sync::Mutex::new(None)),
}
}
}
impl ReloadMailbox {
pub fn new() -> Self {
Self::default()
}
pub fn install(&self, tx: tokio::sync::mpsc::UnboundedSender<()>) {
*self.tx.lock().unwrap() = Some(tx);
}
pub fn signal(&self) -> Result<(), ()> {
let g = self.tx.lock().unwrap();
match &*g {
Some(tx) => {
let _ = tx.send(());
Ok(())
}
None => Err(()),
}
}
pub fn clear(&self) {
*self.tx.lock().unwrap() = None;
}
}
pub fn reload_callback_from_mailbox(
mailbox: ReloadMailbox,
) -> Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> {
Arc::new(move || {
let m = mailbox.clone();
Box::pin(async move {
let _ = m.signal();
})
})
}
async fn dispatch(
host: &Arc<dyn RuntimeActionHost>,
action: RuntimeActionId,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
match action {
RuntimeActionId::SendMessage => host.send_message(args).await,
RuntimeActionId::SendUserMessage => host.send_user_message(args).await,
RuntimeActionId::AppendEntry => host.append_entry(args).await,
RuntimeActionId::SetSessionName => host.set_session_name(args).await,
RuntimeActionId::GetActiveTools => host.get_active_tools(args).await,
RuntimeActionId::SetActiveTools => host.set_active_tools(args).await,
RuntimeActionId::SetModel => host.set_model(args).await,
RuntimeActionId::GetThinkingLevel => host.get_thinking_level(args).await,
RuntimeActionId::SetThinkingLevel => host.set_thinking_level(args).await,
RuntimeActionId::Compact => host.compact(args).await,
RuntimeActionId::GetSystemPrompt => host.get_system_prompt(args).await,
RuntimeActionId::NewSession => host.new_session(args).await,
RuntimeActionId::Fork => host.fork(args).await,
RuntimeActionId::NavigateTree => host.navigate_tree(args).await,
RuntimeActionId::SwitchSession => host.switch_session(args).await,
RuntimeActionId::Reload => host.reload(args).await,
}
}
pub extern "C" fn trampoline_runtime_action(
action: RuntimeActionId,
args_json: StbStringRef,
out: *mut StbString,
user_data: *mut c_void,
) -> i32 {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_action(action, args_json, out, user_data)
}));
match outcome {
Ok(rc) => rc,
Err(_) => {
tracing::error!(
"runtime_action trampoline panicked — aborting (cannot unwind across FFI)"
);
std::process::abort();
}
}
}
fn run_action(
action: RuntimeActionId,
args_json: StbStringRef,
out: *mut StbString,
user_data: *mut c_void,
) -> i32 {
if user_data.is_null() {
return -1;
}
let bridge: &ActionBridge = unsafe { &*(user_data as *const ActionBridge) };
if !bridge.is_active() {
if out.is_null() {
return 1;
}
let json = serde_json::json!({
"error": "runtime_action on a stale ActionBridge (session reloaded/swapped)"
})
.to_string();
unsafe {
*out = StbString::from_string(json);
}
return 1;
}
let args_str = unsafe { args_json.as_str() };
let args: serde_json::Value = if args_str.is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(args_str)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()))
};
let (tx, rx) = mpsc::sync_channel::<Result<serde_json::Value, String>>(1);
let host = Arc::clone(&bridge.host);
let reload_cb = bridge.reload.clone();
bridge.runtime.spawn(async move {
let r = if action == RuntimeActionId::Reload {
if let Some(cb) = reload_cb {
cb().await;
Ok(serde_json::Value::Null)
} else {
host.reload(args).await
}
} else {
dispatch(&host, action, args).await
};
let _ = tx.send(r);
});
let result = match rx.recv() {
Ok(r) => r,
Err(_) => {
return -2;
}
};
if out.is_null() {
return match result {
Ok(_) => 0,
Err(_) => 1,
};
}
let (rc, payload) = match result {
Ok(value) => {
let json = serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string());
(0, json)
}
Err(msg) => {
let json = serde_json::json!({ "error": msg }).to_string();
(1, json)
}
};
unsafe {
*out = StbString::from_string(payload);
}
rc
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct MockHost {
prompt: String,
saw: Mutex<Vec<RuntimeActionId>>,
}
#[async_trait::async_trait]
impl RuntimeActionHost for MockHost {
async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn send_user_message(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn set_session_name(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn get_active_tools(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn set_active_tools(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn get_thinking_level(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn set_thinking_level(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn get_system_prompt(
&self,
_: serde_json::Value,
) -> Result<serde_json::Value, String> {
self.saw
.lock()
.unwrap()
.push(RuntimeActionId::GetSystemPrompt);
Ok(serde_json::json!({ "prompt": self.prompt }))
}
async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
unreachable!("not under test")
}
async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
self.saw.lock().unwrap().push(RuntimeActionId::Reload);
Ok(serde_json::Value::Null)
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trampoline_round_trips_get_system_prompt() {
let host = Arc::new(MockHost {
prompt: "hello from host".to_string(),
saw: Mutex::new(Vec::new()),
});
let host_for_assert = Arc::clone(&host);
let host_dyn: Arc<dyn RuntimeActionHost> = host;
let runtime = tokio::runtime::Handle::current();
let bridge = ActionBridge::new(runtime, host_dyn);
let user_data = Arc::as_ptr(&bridge) as *mut c_void;
let args_str = "{}";
let args_ref = StbStringRef::from_str(args_str);
let mut out = StbString::empty();
let rc = trampoline_runtime_action(
RuntimeActionId::GetSystemPrompt,
args_ref,
&mut out as *mut StbString,
user_data,
);
assert_eq!(rc, 0, "success return code");
let json_text = out.to_string_lossy();
let parsed: serde_json::Value = serde_json::from_str(&json_text).expect("valid json");
assert_eq!(parsed["prompt"], "hello from host");
crate::host_free_string(out);
let saw = host_for_assert.saw.lock().unwrap().clone();
assert_eq!(saw, vec![RuntimeActionId::GetSystemPrompt]);
}
#[tokio::test]
async fn trampoline_null_user_data_returns_minus_one() {
let args_ref = StbStringRef::from_str("{}");
let mut out = StbString::empty();
let rc = trampoline_runtime_action(
RuntimeActionId::GetSystemPrompt,
args_ref,
&mut out as *mut StbString,
std::ptr::null_mut(),
);
assert_eq!(rc, -1, "null user_data ⇒ no bridge");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trampoline_intercepts_reload_when_bridge_has_callback() {
use std::sync::atomic::{AtomicUsize, Ordering};
let reload_calls = Arc::new(AtomicUsize::new(0));
let reload_calls_for_cb = Arc::clone(&reload_calls);
let reload: Arc<dyn Fn() -> futures::future::BoxFuture<'static, ()> + Send + Sync> =
Arc::new(move || {
let c = Arc::clone(&reload_calls_for_cb);
Box::pin(async move {
c.fetch_add(1, Ordering::SeqCst);
})
});
let host = Arc::new(MockHost {
prompt: String::new(),
saw: Mutex::new(Vec::new()),
});
let host_for_assert = Arc::clone(&host);
let host_dyn: Arc<dyn RuntimeActionHost> = host;
let runtime = tokio::runtime::Handle::current();
let bridge = ActionBridge::with_reload(runtime, host_dyn, reload);
let user_data = Arc::as_ptr(&bridge) as *mut c_void;
let args_ref = StbStringRef::from_str("{}");
let mut out = StbString::empty();
let rc = trampoline_runtime_action(
RuntimeActionId::Reload,
args_ref,
&mut out as *mut StbString,
user_data,
);
assert_eq!(rc, 0);
assert_eq!(reload_calls.load(Ordering::SeqCst), 1);
assert!(host_for_assert.saw.lock().unwrap().is_empty());
crate::host_free_string(out);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn trampoline_rejects_stale_bridge_with_invalidate() {
let host = Arc::new(MockHost {
prompt: String::new(),
saw: Mutex::new(Vec::new()),
});
let host_for_assert = Arc::clone(&host);
let host_dyn: Arc<dyn RuntimeActionHost> = host;
let runtime = tokio::runtime::Handle::current();
let bridge = ActionBridge::new(runtime, host_dyn);
let user_data = Arc::as_ptr(&bridge) as *mut c_void;
bridge.invalidate();
assert!(!bridge.is_active());
let args_ref = StbStringRef::from_str("{}");
let mut out = StbString::empty();
let rc = trampoline_runtime_action(
RuntimeActionId::GetSystemPrompt,
args_ref,
&mut out as *mut StbString,
user_data,
);
assert_eq!(rc, 1, "stale bridge ⇒ error return code");
let json_text = out.to_string_lossy();
assert!(
json_text.contains("stale"),
"stale-bridge error payload: {json_text}"
);
crate::host_free_string(out);
assert!(
host_for_assert.saw.lock().unwrap().is_empty(),
"host dispatch must NOT run on a stale bridge"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reload_mailbox_signals_installed_receiver() {
let mailbox = ReloadMailbox::new();
assert!(matches!(mailbox.signal(), Err(())));
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
mailbox.install(tx);
let cb = reload_callback_from_mailbox(mailbox.clone());
cb().await;
assert_eq!(
rx.recv().await,
Some(()),
"installed receiver saw the signal"
);
mailbox.clear();
}
}