diff --git a/Cargo.lock b/Cargo.lock index 4e93ea1..60ca8f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,6 +826,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.8" @@ -1128,8 +1138,10 @@ name = "picotest" version = "1.8.1" dependencies = [ "anyhow", + "base64 0.22.1", "constcat", "ctor", + "libloading", "picotest_helpers", "picotest_macros", "postgres", diff --git a/picotest/Cargo.toml b/picotest/Cargo.toml index 976c8b2..0213c32 100644 --- a/picotest/Cargo.toml +++ b/picotest/Cargo.toml @@ -23,6 +23,8 @@ picotest_macros = { path = "../picotest_macros", version = "1.8.1" } picotest_helpers = { path = "../picotest_helpers", version = "1.8.1" } anyhow.workspace = true ctor = "0.6.0" +libloading = "0.8.9" +base64 = "0.22.1" rstest.workspace = true serde.workspace = true diff --git a/picotest/src/internal.rs b/picotest/src/internal.rs index a9ed298..881af83 100644 --- a/picotest/src/internal.rs +++ b/picotest/src/internal.rs @@ -105,7 +105,7 @@ pub fn lua_ffi_call_unit_test(test_fn_name: &str, plugin_dylib_path: &str) -> St "[*] Running unit-test '{test_fn_name}'" ffi = require("ffi") -ffi.cdef[[void {test_fn_name}();]] +ffi.cdef[[void ({test_fn_name})();]] dylib = "{plugin_dylib_path}" ffi.load(dylib).{test_fn_name}() @@ -114,6 +114,20 @@ true"# ) } +/// Replaces original test implementation in test binary, executes its payload on picodata +/// instance and passes back result of test run. +/// +/// ### Arguments +/// - `package_name` - name of plugin to load into picodata, on picotest_unit side +/// it should be provided via env!(CARGO_PKG_NAME) +/// - `test_locator_name` - FFI-exposed function, which stores information about test. +/// As of now, it calls the test by itself. +/// - `test_display_name` - fully-qualified test name. +pub fn execute_test(package_name: &str, test_locator_name: &str, test_display_name: &str) { + let runner = crate::runner::get_test_runner(package_name); + runner.execute_unit(test_display_name, test_locator_name); +} + pub fn verify_unit_test_output(output: &str) -> anyhow::Result<()> { if output.contains("cannot open shared object file") { bail!("failed to open plugin shared library") diff --git a/picotest/src/lib.rs b/picotest/src/lib.rs index 44bb26a..1c2c5c5 100644 --- a/picotest/src/lib.rs +++ b/picotest/src/lib.rs @@ -6,6 +6,7 @@ pub use rstest::*; pub use std::{panic, path::PathBuf, sync::OnceLock, time::Duration}; pub mod internal; +pub mod runner; pub static SESSION_CLUSTER: OnceLock = OnceLock::new(); diff --git a/picotest/src/runner/client.rs b/picotest/src/runner/client.rs new file mode 100644 index 0000000..3d2c9a1 --- /dev/null +++ b/picotest/src/runner/client.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; + +use super::{PicotestRunner, TestResult, TestStatus }; +use crate::get_or_create_session_cluster; +use crate::internal::verify_unit_test_output; +use crate::internal::{get_or_create_unit_test_topology, plugin_dylib_path, plugin_root_dir}; +use picotest_helpers::Cluster; + +struct RemotePicotestRunner { + #[allow(unused)] + package_name: String, + plugin_dylib_path: PathBuf, + cluster: &'static Cluster, +} + +impl PicotestRunner for RemotePicotestRunner { + fn execute_unit(&self, name: &str, locator_name: &str) -> TestResult { + let package_name = &self.package_name; + let dylib_path = self.plugin_dylib_path.to_str().unwrap(); + let call_server_side = format!( + r#" +"[*] Running unit-test '{name}'" + +ffi = require("ffi") +_G.__picotest = _G.__picotest or {{ }} +_G.__picotest_result = _G.__picotest_result or ffi.cdef[[ typedef struct {{ uint8_t fail; char* data; size_t len; size_t cap; }} picounit_result; ]] +_G.__picotest_exec_unit = _G.__picotest_exec_unit or ffi.cdef[[ picounit_result (picotest_execute_unit)(const char*, const char*, const char*); ]] +_G.__picotest_free_unit = _G.__picotest_free_unit or ffi.cdef[[ void (picotest_free_unit_result)(picounit_result); ]] +_G.__picotest["{package_name}"] = _G.__picotest["{package_name}"] or {{ lib = ffi.load("{dylib_path}") }} + +result = _G.__picotest["{package_name}"].lib.picotest_execute_unit("{package_name}","{name}","{locator_name}") + +"[*] Test '{name}' has been finished" +("picotest_unit|{name}|fail=%s"):format(result.fail) +("picotest_unit|{name}|data=%s"):format(ffi.string(result.data,result.len)) + +_G.__picotest["{package_name}"].lib.picotest_free_unit_result(result) +true"# + ); + + let output = self.cluster + .run_lua(call_server_side) + .expect("Failed to execute query"); + + let test_out_prefix = format!("- picotest_unit|{name}|"); + let mut fail = false; + let (mut payload,mut location,mut backtrace): (String,Option,Option) = (String::new(),None,None); + for line in output.split("\n") { + if !line.starts_with(&test_out_prefix) { + continue + } + let line = line.strip_prefix(&test_out_prefix).unwrap(); + if !line.contains("=") { + continue; + } + + let (key,value) = line.split_once("=").unwrap(); + if key == "fail" && value == "1" { + fail = true + } + if key == "data" { + (payload, location, backtrace) = super::server::PicotestPanicInfo::decode_with_base64(value); + } + } + + if fail { + let data = { + let mut out = String::with_capacity(backtrace.as_ref().map(|b| b.len()).unwrap_or(0)+200); + let location = location.unwrap_or(String::from("")); + out += &format!("remote fiber panicked at {}:\n{}",location,payload); + if let Some(backtrace) = backtrace { + out += "\nremote stack backtrace:\n"; + out += &backtrace; + } + out + }; + panic!("{}",data); + } + if let Err(err) = verify_unit_test_output(&output) { + for l in output.split("----") { + println!("[Lua] {l}") + } + panic!("Test '{name}' exited with failure: {}", err); + } + TestResult { status: TestStatus::Success } + } +} + +pub fn create_test_runner(package_name: &str) -> impl PicotestRunner { + let package_name = package_name.to_string(); + let plugin_path = plugin_root_dir(); + let plugin_dylib_path = plugin_dylib_path(&plugin_path, &package_name); + let plugin_topology = get_or_create_unit_test_topology(); + + let cluster = get_or_create_session_cluster( + plugin_path.to_str().unwrap().into(), + plugin_topology.into(), + 0, + ); + + RemotePicotestRunner { + package_name, + cluster, + plugin_dylib_path, + } +} diff --git a/picotest/src/runner/mod.rs b/picotest/src/runner/mod.rs new file mode 100644 index 0000000..a7a5077 --- /dev/null +++ b/picotest/src/runner/mod.rs @@ -0,0 +1,46 @@ +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex, OnceLock}; + +mod client; +mod server; + +static IS_SERVER_SIDE: OnceLock = OnceLock::new(); +static RUNNERS_MAP: LazyLock>>> = LazyLock::new(|| { + Mutex::new(HashMap::with_capacity(1)) +}); + +pub fn running_as_server() -> bool { + *IS_SERVER_SIDE.get_or_init(detect_run_as_server) +} + +fn detect_run_as_server() -> bool { + let exe_path = std::env::current_exe().unwrap(); + exe_path.ends_with("picodata") +} + +pub type UnitTestLocator = extern "C" fn(); + +pub enum TestStatus { + Success, + Failure, +} + +pub struct TestResult { + pub status: TestStatus, +} + +pub trait PicotestRunner: Sync + Send { + fn execute_unit(&self, name: &str, locator_name: &str) -> TestResult; +} + +pub fn get_test_runner(package_name: &str) -> Arc { + assert!(!running_as_server()); + + let mut runners_map = RUNNERS_MAP.lock().unwrap(); + if let Some(package_test_runner) = runners_map.get(package_name) { + return Arc::clone(package_test_runner) + } + let new_runner = Arc::new(client::create_test_runner(package_name)) as Arc; + runners_map.insert(String::from(package_name), Arc::clone(&new_runner)); + new_runner +} \ No newline at end of file diff --git a/picotest/src/runner/server.rs b/picotest/src/runner/server.rs new file mode 100644 index 0000000..60426d0 --- /dev/null +++ b/picotest/src/runner/server.rs @@ -0,0 +1,229 @@ +use std::backtrace::{self, Backtrace, BacktraceStatus}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::ffi::{c_char, CStr}; +use std::panic::{catch_unwind, Location, PanicHookInfo, UnwindSafe}; +use std::sync::OnceLock; + +use base64::prelude::BASE64_STANDARD_NO_PAD; +use base64::Engine; +use libloading::os::unix::{Library, Symbol}; + +use crate::internal::{plugin_dylib_path, plugin_root_dir}; + +unsafe extern "C" { + fn fiber_id(fiber: *mut c_char) -> u64; + fn fiber_set_name_n(fiber: *mut c_char, name: *const c_char, len: u32); +} + +type PanicHook = Box) + 'static + Sync + Send>; + +struct PicotestPanicLocation { + file: String, + line: u32, + col: u32, +} + +impl From<&Location<'_>> for PicotestPanicLocation { + fn from(value: &Location) -> Self { + Self { + file: value.file().to_string(), + line: value.line(), + col: value.column(), + } + } +} + +pub struct PicotestPanicInfo { + payload_str: String, + backtrace: Backtrace, + location: Option, +} + +impl PicotestPanicInfo { + fn encode_with_base64(&self) -> String { + let mut output = String::with_capacity(512); + output += "payload:"; + BASE64_STANDARD_NO_PAD.encode_string(&self.payload_str, &mut output); + if let Some(location) = &self.location { + output += ";location:"; + let loc_short = format!("{}:{}:{}", location.file, location.line, location.col); + BASE64_STANDARD_NO_PAD.encode_string(loc_short, &mut output); + } + if self.backtrace.status() == BacktraceStatus::Captured { + output += ";backtrace:"; + let backtrace_str = match std::env::var("RUST_BACKTRACE") { + Ok(s) if s == "full" => format!("{:#}",self.backtrace), + _ => format!("{}",self.backtrace), + }; + BASE64_STANDARD_NO_PAD.encode_string(backtrace_str.strip_suffix("\n").unwrap(), &mut output); + } + output += ";"; + output + } + + pub fn decode_with_base64(data: &str) -> (String,Option,Option) { + assert!(data.starts_with("payload:")); + let tail = data.strip_prefix("payload:").unwrap(); + let (payload_value,mut tail) = tail.split_once(";").unwrap(); + let payload = String::from_utf8(BASE64_STANDARD_NO_PAD.decode(payload_value).unwrap()).unwrap(); + + let location = if tail.starts_with("location:") { + tail = tail.strip_prefix("location:").unwrap(); + let (location_value, new_tail) = tail.split_once(';').unwrap(); + let location = String::from_utf8(BASE64_STANDARD_NO_PAD.decode(location_value).unwrap()).unwrap(); + tail = new_tail; + Some(location) + } else { + None + }; + + let backtrace = if tail.starts_with("backtrace:") { + tail = tail.strip_prefix("backtrace:").unwrap(); + let (backtrace_value, _) = tail.split_once(';').unwrap(); + Some(String::from_utf8(BASE64_STANDARD_NO_PAD.decode(backtrace_value).unwrap()).unwrap()) + } else { + None + }; + + (payload,location,backtrace) + } +} + +thread_local! { + static RAISED_PANICS: RefCell> = RefCell::new(HashMap::with_capacity(16)); + static GUARDED_FIBERS: RefCell> = RefCell::new(HashSet::with_capacity(100)) +} + +static PICOPLUGIN_HANDLER: OnceLock = OnceLock::new(); + +fn install_picotest_panic_hook() { + let mut first_install = false; + PICOPLUGIN_HANDLER.get_or_init(|| { + first_install = true; + std::panic::take_hook() + }); + if first_install { + let boxed_hook = Box::new(picotest_panic_hook); + std::panic::set_hook(boxed_hook); + } +} + +fn picotest_panic_hook(info: &PanicHookInfo<'_>) { + let current_id = unsafe { fiber_id(std::ptr::null_mut()) }; + let is_guarded = GUARDED_FIBERS.with(|set_cell| set_cell.borrow().contains(¤t_id)); + if !is_guarded { + let original_handler = PICOPLUGIN_HANDLER + .get() + .expect("install_hook must extract original handler"); + original_handler.as_ref()(info); + } + + let backtrace = backtrace::Backtrace::capture(); + let location = info.location().map(|l| PicotestPanicLocation::from(l)); + let payload_str = if let Some(s) = info.payload().downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = info.payload().downcast_ref::() { + s.to_string() + } else { + String::from("unknown panic") + }; + RAISED_PANICS.with_borrow_mut(move |state| { + state.insert( + current_id, + PicotestPanicInfo { + backtrace, + payload_str, + location, + }, + ); + }); + // trigger unwinding to std::panic::catch_unwind by exiting this handler +} + +fn fiber_catch_unwind(f: F) -> Result +where + F: FnOnce() -> R + UnwindSafe, +{ + install_picotest_panic_hook(); + let current_fiber = unsafe { fiber_id(std::ptr::null_mut()) }; + GUARDED_FIBERS.with(|map_cell| map_cell.borrow_mut().insert(current_fiber)); + let result = catch_unwind(f); + GUARDED_FIBERS.with(|map_cell| map_cell.borrow_mut().remove(¤t_fiber)); + result.map_err(|_| RAISED_PANICS.with_borrow_mut(|map| map.remove(¤t_fiber).unwrap())) +} + +#[repr(C)] +pub struct PicounitResult { + fail: u8, + data: *mut c_char, + len: u32, + cap: u32, +} + +impl Default for PicounitResult { + fn default() -> Self { + Self { + fail: 0, + data: std::ptr::null_mut(), + len: 0, + cap: 0, + } + } +} + +impl PicounitResult { + fn failure(err: PicotestPanicInfo) -> Self { + let mut data_string = err.encode_with_base64(); + let len = data_string.len(); + let cap = data_string.capacity(); + let data = data_string.as_mut_ptr(); + std::mem::forget(data_string); + Self { + fail: 1, + data: data as *mut i8, + len: len as u32, + cap: cap as u32, + } + } +} + +#[allow(unused)] +#[unsafe(no_mangle)] +unsafe extern "C" fn picotest_free_unit_result(result: PicounitResult) { + if result.data != std::ptr::null_mut() { + let data_string = + String::from_raw_parts(result.data as _, result.len as _, result.cap as _); + drop(data_string) + } +} + +#[allow(unused)] +#[unsafe(no_mangle)] +unsafe extern "C" fn picotest_execute_unit( + package: *const c_char, + name: *const c_char, + locator_name: *const c_char, +) -> PicounitResult { + let package = CStr::from_ptr(package).to_str().unwrap().to_string(); + let name_str = CStr::from_ptr(name).to_str().unwrap().to_string(); + let locator_name = CStr::from_ptr(locator_name).to_str().unwrap().to_string(); + + let plugin_path = plugin_root_dir(); + let dylib_path = plugin_dylib_path(&plugin_path, &package); + let dylib_path = dylib_path.to_str().unwrap(); + + let test_lib = Library::new(dylib_path).unwrap(); + let locator_fn: Symbol fn()> = + test_lib.get(locator_name.as_bytes()).unwrap(); + let test_fn = (locator_fn)(); + + fiber_set_name_n(std::ptr::null_mut(), name, name_str.len() as u32); + + let result = fiber_catch_unwind(|| test_fn()); + + match result { + Ok(..) => PicounitResult::default(), + Err(error) => PicounitResult::failure(error), + } +} diff --git a/picotest_macros/src/lib.rs b/picotest_macros/src/lib.rs index b193fc8..28b22e3 100644 --- a/picotest_macros/src/lib.rs +++ b/picotest_macros/src/lib.rs @@ -4,7 +4,8 @@ use darling::ast::NestedMeta; use darling::{Error, FromMeta}; use proc_macro::TokenStream; use quote::quote; -use syn::{parse, parse_macro_input, parse_quote, Ident, Item, ItemFn}; +use syn::parse::{Parse, ParseStream}; +use syn::{parse_macro_input, parse_quote, Error as SynError, Ident, Item, ItemFn}; fn plugin_timeout_secs_default() -> u64 { 5 @@ -17,6 +18,15 @@ fn parse_attrs(attr: TokenStream) -> Result { .map_err(|e| TokenStream::from(e.write_errors())) } +macro_rules! parse_attrs { + ($attr:ident as $ty:ty) => { + match parse_attrs::<$ty>($attr) { + Ok(obj) => obj, + Err(err) => return err, + } + }; +} + #[derive(Debug, FromMeta)] struct PluginCfg { path: Option, @@ -24,13 +34,30 @@ struct PluginCfg { timeout: u64, } +#[derive(Debug, FromMeta)] +struct UnitTestAttributes { +} + +struct UnitTestFunction { + func: ItemFn, +} + +impl Parse for UnitTestFunction { + fn parse(input: ParseStream) -> Result { + let func = input.parse::().map_err(|err| { + SynError::new( + err.span(), + "The #[picotest_unit] macro is only valid when called on a function.", + ) + })?; + Ok(UnitTestFunction { func }) + } +} + #[proc_macro_attribute] pub fn picotest(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as Item); - let cfg: PluginCfg = match parse_attrs(attr) { - Ok(cfg) => cfg, - Err(err) => return err, - }; + let cfg = parse_attrs!(attr as PluginCfg); let path = cfg.path; let timeout_secs = cfg.timeout; @@ -70,75 +97,51 @@ pub fn picotest(attr: TokenStream, item: TokenStream) -> TokenStream { static UNIT_COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1); #[proc_macro_attribute] -pub fn picotest_unit(_: TokenStream, tokens: TokenStream) -> TokenStream { - match parse_macro_input!(tokens as Item) { - Item::Fn(mut test_fn) => { - let test_fn_attrs = test_fn.attrs.clone(); - let test_fn_name = test_fn.sig.ident.to_string(); - // We want test routine to be called through FFI. - // So mark it as 'pub extern "C"'. - test_fn.vis = parse_quote! { pub }; - test_fn.sig.abi = parse_quote! { extern "C" }; - // Set no mangle attribute to avoid spoiling of function signature. - test_fn.attrs = vec![ - parse_quote! { #[allow(dead_code)] }, - parse_quote! { #[unsafe(no_mangle)] }, - ]; - - // Create test runner - it's a wrapper around main test function. - // This wrapper will call main test routine in a Lua runtime running - // inside picodata instance. - let test_runner_ident = test_fn.sig.ident.clone(); - - // Name of the function to be invoked on instance-side as test payload - let test_idx = UNIT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Acquire); - let ffi_test_callable = format!("test_impl_{test_idx}_{test_fn_name}"); - test_fn.sig.ident = Ident::new(&ffi_test_callable, test_fn.sig.ident.span()); - - let tokens = quote! { - #[test] - fn #test_runner_ident() { - use picotest::internal; - - let plugin_path = internal::plugin_root_dir(); - let plugin_dylib_path = - internal::plugin_dylib_path(&plugin_path, env!("CARGO_PKG_NAME")); - let plugin_topology = internal::get_or_create_unit_test_topology(); - - let call_test_fn_query = - internal::lua_ffi_call_unit_test( - #ffi_test_callable, plugin_dylib_path.to_str().unwrap()); - - let cluster = picotest::get_or_create_session_cluster( - plugin_path.to_str().unwrap().into(), - plugin_topology.into(), - 0 - ); - - let output = cluster.run_lua(call_test_fn_query) - .expect("Failed to execute query"); - - if let Err(err) = internal::verify_unit_test_output(&output) { - for l in output.split("----") { - println!("[Lua] {l}") - } - panic!("Test '{}' exited with failure: {}", #test_fn_name, err); - } - } - }; - - let mut test_runner: ItemFn = - parse(tokens.into()).expect("Runner routine tokens must be parsed"); - - // Preserve attributes added to source test routine. - test_runner.attrs.extend(test_fn_attrs); +pub fn picotest_unit(attrs: TokenStream, tokens: TokenStream) -> TokenStream { + let _attrs = parse_attrs!(attrs as UnitTestAttributes); + let test_fn = parse_macro_input!(tokens as UnitTestFunction).func; + + let test_fn_attrs = test_fn.attrs.clone(); + let test_fn_ident = test_fn.sig.ident.clone(); + let test_fn_block = test_fn.block; + let test_fn_name = test_fn.sig.ident.to_string(); + + // Name of the function to be invoked on instance-side to obtain test information + let test_idx = UNIT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Acquire); + let test_locator_name = format!("test_impl_{test_idx}_{test_fn_name}"); + let test_locator_ident = Ident::new(&test_locator_name, test_fn_ident.span()); + + // Render 2 functions: + // 1. Exported test function locator, which can be accessed via lua on demand to + // run non-exported function with test payload. Locator name uses autogenerated + // name, as procedural macro can not resolve current path with std::module_path!() + // due to macro expansion ordering. + // 2. Test function with same name this macro was provided. Depending on build + // profile (test or non-test) this function does contain either client side + // of test (which starts picodata cluster, setups plugin and enters instance via lua), + // or server side of test (with payload we have been told to wrap) + quote! { + #[allow(dead_code)] + #[unsafe(no_mangle)] + pub extern "C" fn #test_locator_ident() -> fn() { + #test_fn_ident + } - quote! { - #test_fn - #test_runner + #[cfg_attr(test,test)] + #[cfg_attr(not(test),inline(never))] + #( #test_fn_attrs )* + fn #test_fn_ident() { + #[cfg(not(test))] + { + #test_fn_block + } + #[cfg(test)] + { + const TEST_FULL_NAME: &'static str = concat!(std::module_path!(), "::", #test_fn_name); + let (pkg_name, test_name) = TEST_FULL_NAME.split_once("::").unwrap(); + picotest::internal::execute_test(pkg_name, #test_locator_name, test_name); } - .into() } - _ => panic!("The #[picotest_unit] macro is only valid when called on a function."), } + .into() }