feat: implement protocol v2 for plugin system and add comprehensive tracing support
Release / check-release (push) Successful in 16s
Release / build (push) Skipped
Release / package (deb) (push) Skipped
Release / package (rpm) (push) Skipped
Release / publish (push) Skipped
CI / test (push) Successful in 1m58s

This commit is contained in:
Elias Wendland
2026-07-17 17:46:14 +02:00
parent 590c616d6f
commit f07f54d4c1
12 changed files with 983 additions and 143 deletions
+1
View File
@@ -8,3 +8,4 @@ repository.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
+153 -11
View File
@@ -13,12 +13,25 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
use std::{
collections::BTreeMap,
ffi::{CStr, CString, c_char},
path::PathBuf,
};
pub const API_VERSION: u32 = 1;
/// The newest stable wire protocol implemented by this SDK.
pub const API_VERSION: u32 = 2;
pub const ENGINE_VERSION: &str = "0.3.0";
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest_v1\0";
pub const FACTORY_SYMBOL: &[u8] = b"convertis_plugin_create_v1\0";
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest\0";
pub const METADATA_SYMBOL_V2: &[u8] = b"convertis_plugin_metadata_v2\0";
pub const AVAILABILITY_SYMBOL_V2: &[u8] = b"convertis_plugin_availability_v2\0";
pub const CONVERT_SYMBOL_V2: &[u8] = b"convertis_plugin_convert_v2\0";
pub const FREE_SYMBOL_V2: &[u8] = b"convertis_plugin_free_string_v2\0";
// Protocol v1 crossed the Rust ABI boundary. These names are kept forever so
// engines can retain an adapter for plugins built before the stable protocol.
pub const LEGACY_MANIFEST_SYMBOL_V1: &[u8] = b"convertis_plugin_manifest_v1\0";
pub const LEGACY_FACTORY_SYMBOL_V1: &[u8] = b"convertis_plugin_create_v1\0";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ArtifactKind {
@@ -79,7 +92,7 @@ pub struct PluginMetadata {
pub options: Vec<OptionSpec>,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConversionRequest {
pub input: PathBuf,
pub output: PathBuf,
@@ -98,19 +111,77 @@ pub trait Plugin: Send + Sync {
pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>;
pub type PluginManifest = unsafe extern "C" fn() -> *const std::ffi::c_char;
pub type PluginJsonCall = unsafe extern "C" fn() -> *mut c_char;
pub type PluginConvertCall = unsafe extern "C" fn(*const c_char) -> *mut c_char;
pub type PluginStringFree = unsafe extern "C" fn(*mut c_char);
#[derive(Debug, Serialize, Deserialize)]
pub struct WireResponse<T> {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl<T> WireResponse<T> {
pub fn success(result: T) -> Self {
Self {
ok: true,
result: Some(result),
error: None,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
ok: false,
result: None,
error: Some(error.into()),
}
}
}
#[doc(hidden)]
pub fn wire_json<T: Serialize>(response: WireResponse<T>) -> *mut c_char {
let json = serde_json::to_string(&response).unwrap_or_else(|_| {
"{\"ok\":false,\"error\":\"plugin response serialization failed\"}".to_owned()
});
CString::new(json)
.expect("JSON serialization cannot contain a NUL byte")
.into_raw()
}
#[doc(hidden)]
pub unsafe fn request_from_json(pointer: *const c_char) -> Result<ConversionRequest, String> {
if pointer.is_null() {
return Err("conversion request was null".to_owned());
}
let json = unsafe { CStr::from_ptr(pointer) }
.to_str()
.map_err(|error| format!("conversion request was not UTF-8: {error}"))?;
serde_json::from_str(json).map_err(|error| format!("invalid conversion request: {error}"))
}
#[doc(hidden)]
pub unsafe fn free_wire_string(pointer: *mut c_char) {
if !pointer.is_null() {
drop(unsafe { CString::from_raw(pointer) });
}
}
#[macro_export]
macro_rules! export_plugin {
($plugin:expr, $id:literal) => {
#[unsafe(no_mangle)]
pub extern "C" fn convertis_plugin_manifest_v1() -> *const std::ffi::c_char {
pub extern "C" fn convertis_plugin_manifest() -> *const std::ffi::c_char {
static MANIFEST: &str = concat!(
"{\"api_version\":1,\"engine_version\":\"",
"{\"manifest_version\":1,\"protocol_versions\":[2],\"engine_requirement\":\">=",
env!("CARGO_PKG_VERSION"),
"\",\"plugin_id\":\"",
$id,
"\",\"rustc_version\":\"",
env!("CONVERTIS_RUSTC_VERSION"),
"\",\"plugin_version\":\"",
env!("CARGO_PKG_VERSION"),
"\",\"target\":\"",
env!("CONVERTIS_TARGET"),
"\"}\0"
@@ -119,8 +190,79 @@ macro_rules! export_plugin {
}
#[unsafe(no_mangle)]
pub fn convertis_plugin_create_v1() -> Box<dyn $crate::Plugin> {
Box::new($plugin)
pub extern "C" fn convertis_plugin_metadata_v2() -> *mut std::ffi::c_char {
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
$crate::Plugin::metadata(&$plugin)
}))
.map($crate::WireResponse::success)
.unwrap_or_else(|_| {
$crate::WireResponse::failure("plugin panicked while returning metadata")
});
$crate::wire_json(response)
}
#[unsafe(no_mangle)]
pub extern "C" fn convertis_plugin_availability_v2() -> *mut std::ffi::c_char {
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
$crate::Plugin::availability(&$plugin)
}));
let response = match response {
Ok(Ok(())) => $crate::WireResponse::success(()),
Ok(Err(error)) => $crate::WireResponse::failure(error),
Err(_) => {
$crate::WireResponse::failure("plugin panicked while checking availability")
}
};
$crate::wire_json(response)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn convertis_plugin_convert_v2(
request: *const std::ffi::c_char,
) -> *mut std::ffi::c_char {
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe {
$crate::request_from_json(request)
.and_then(|request| $crate::Plugin::convert(&$plugin, &request))
}));
let response = match response {
Ok(Ok(())) => $crate::WireResponse::success(()),
Ok(Err(error)) => $crate::WireResponse::failure(error),
Err(_) => $crate::WireResponse::failure("plugin panicked during conversion"),
};
$crate::wire_json(response)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn convertis_plugin_free_string_v2(pointer: *mut std::ffi::c_char) {
unsafe { $crate::free_wire_string(pointer) }
}
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conversion_requests_round_trip_through_json() {
let request = ConversionRequest {
input: "/tmp/input.png".into(),
output: "/tmp/output.jpg".into(),
from: "png".into(),
to: "jpeg".into(),
options: BTreeMap::from([("quality".into(), "90".into())]),
};
let json = CString::new(serde_json::to_string(&request).unwrap()).unwrap();
let decoded = unsafe { request_from_json(json.as_ptr()) }.unwrap();
assert_eq!(decoded.input, request.input);
assert_eq!(decoded.options, request.options);
}
#[test]
fn unit_success_is_explicit_even_though_json_result_is_null() {
let json = serde_json::to_string(&WireResponse::success(())).unwrap();
let decoded: WireResponse<()> = serde_json::from_str(&json).unwrap();
assert!(decoded.ok);
assert!(decoded.error.is_none());
}
}