diff --git a/changelog.d/8441-remove-legacy-compose-ffi.md b/changelog.d/8441-remove-legacy-compose-ffi.md new file mode 100644 index 0000000000..6dc36aeb36 --- /dev/null +++ b/changelog.d/8441-remove-legacy-compose-ffi.md @@ -0,0 +1,6 @@ +### Fixed + +- Removed `perry-container-compose`'s unused legacy `ffi` feature and duplicate + `js_compose_*` exports, which implemented a 4-byte string header incompatible + with the runtime's 20-byte string ABI. Compose FFI remains available through + the canonical stack-handle exports in `perry-stdlib`. diff --git a/crates/perry-container-compose/Cargo.toml b/crates/perry-container-compose/Cargo.toml index 685487ca4e..b67680ee14 100644 --- a/crates/perry-container-compose/Cargo.toml +++ b/crates/perry-container-compose/Cargo.toml @@ -40,8 +40,6 @@ proptest = "1" [features] default = [] -ffi = [] # Enable FFI exports for Perry TypeScript integration (legacy YAML-path shape; - # do NOT combine with perry-stdlib container feature — would link-collide) # Live-runtime integration tests (require a real OCI runtime). The # functional tests under tests/functional_orchestration.rs need # `MockBackend` exposed, so this implies `test-utils`. diff --git a/crates/perry-container-compose/src/ffi.rs b/crates/perry-container-compose/src/ffi.rs deleted file mode 100644 index 2ce9d53e3e..0000000000 --- a/crates/perry-container-compose/src/ffi.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! FFI exports for Perry TypeScript integration. -//! -//! Each function follows the Perry FFI convention: -//! - String arguments arrive as `*const StringHeader` (Perry runtime layout) -//! - Results are serialised to JSON strings before being handed back to JS - -use crate::compose::ComposeEngine; -use std::path::PathBuf; -use std::sync::Arc; - -// ────────────────────────────────────────────────────────────── -// Minimal re-implementation of the Perry runtime string types -// ────────────────────────────────────────────────────────────── - -#[repr(C)] -pub struct StringHeader { - pub length: u32, -} - -unsafe fn string_from_header(ptr: *const StringHeader) -> Option { - if ptr.is_null() || (ptr as usize) < 0x1000 { - return None; - } - let len = (*ptr).length as usize; - let data_ptr = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data_ptr, len); - Some(String::from_utf8_lossy(bytes).into_owned()) -} - -// ────────────────────────────────────────────────────────────── -// Helpers -// ────────────────────────────────────────────────────────────── - -fn json_ok(value: &str) -> *const StringHeader { - let payload = format!("{{\"ok\":true,\"result\":{}}}", value); - heap_string(payload) -} - -fn json_err(message: &str) -> *const StringHeader { - let escaped = message.replace('"', "\\\""); - let payload = format!("{{\"ok\":false,\"error\":\"{}\"}}", escaped); - heap_string(payload) -} - -fn heap_string(s: String) -> *const StringHeader { - let bytes = s.into_bytes(); - let total = std::mem::size_of::() + bytes.len(); - let layout = std::alloc::Layout::from_size_align(total, std::mem::align_of::()) - .expect("layout"); - unsafe { - let ptr = std::alloc::alloc(layout) as *mut StringHeader; - (*ptr).length = bytes.len() as u32; - let data_ptr = (ptr as *mut u8).add(std::mem::size_of::()); - std::ptr::copy_nonoverlapping(bytes.as_ptr(), data_ptr, bytes.len()); - ptr as *const StringHeader - } -} - -fn block, T>(fut: F) -> T { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime") - .block_on(fut) -} - -fn parse_compose_file(file_ptr: *const StringHeader) -> Option { - unsafe { string_from_header(file_ptr) }.map(PathBuf::from) -} - -fn parse_services_filter(raw: Option) -> Result, &'static str> { - match raw { - None => Ok(Vec::new()), - Some(raw) if raw.trim().is_empty() => Ok(Vec::new()), - Some(raw) => serde_json::from_str(&raw).map_err(|_| "services must be a JSON array"), - } -} - -fn make_engine(files: Vec) -> Result, String> { - let config = crate::config::ProjectConfig::new(files, None, Vec::new()); - let proj = crate::project::ComposeProject::load(&config).map_err(|e| e.to_string())?; - let backend: Arc = - match block(crate::backend::detect_backend()) { - Ok(b) => Arc::from(b), - Err(e) => return Err(format!("{:?}", e)), - }; - Ok(Arc::new(ComposeEngine::new( - proj.spec, - proj.project_name, - backend, - ))) -} - -// ────────────────────────────────────────────────────────────── -// Exported FFI functions -// ────────────────────────────────────────────────────────────── - -#[no_mangle] -pub unsafe extern "C" fn js_compose_start(file_ptr: *const StringHeader) -> *const StringHeader { - let files: Vec = parse_compose_file(file_ptr).into_iter().collect(); - match make_engine(files) { - Err(e) => json_err(&e), - Ok(engine) => match block(engine.up(&[], true, false, false)) { - Ok(_) => json_ok("null"), - Err(e) => json_err(&e.to_string()), - }, - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_compose_stop(file_ptr: *const StringHeader) -> *const StringHeader { - let files: Vec = parse_compose_file(file_ptr).into_iter().collect(); - match make_engine(files) { - Err(e) => json_err(&e), - Ok(engine) => match block(engine.down(&[], false, false)) { - Ok(_) => json_ok("null"), - Err(e) => json_err(&e.to_string()), - }, - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_compose_ps(file_ptr: *const StringHeader) -> *const StringHeader { - let files: Vec = parse_compose_file(file_ptr).into_iter().collect(); - match make_engine(files) { - Err(e) => json_err(&e), - Ok(engine) => match block(engine.ps()) { - Err(e) => json_err(&e.to_string()), - Ok(infos) => { - let items: Vec = infos - .iter() - .map(|i| { - format!( - "{{\"service\":\"{}\",\"container\":\"{}\",\"status\":\"{}\"}}", - i.name, i.id, i.status - ) - }) - .collect(); - let array = format!("[{}]", items.join(",")); - json_ok(&array) - } - }, - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_compose_logs( - file_ptr: *const StringHeader, - services_ptr: *const StringHeader, - _follow: bool, -) -> *const StringHeader { - let files: Vec = parse_compose_file(file_ptr).into_iter().collect(); - let services = match parse_services_filter(string_from_header(services_ptr)) { - Ok(services) => services, - Err(message) => return json_err(message), - }; - - match make_engine(files) { - Err(e) => json_err(&e), - Ok(engine) => match block(engine.logs(&services, None)) { - Err(e) => json_err(&e.to_string()), - Ok(logs) => match serde_json::to_string(&logs) { - Ok(payload) => json_ok(&payload), - Err(e) => json_err(&e.to_string()), - }, - }, - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_compose_exec( - file_ptr: *const StringHeader, - service_ptr: *const StringHeader, - cmd_ptr: *const StringHeader, -) -> *const StringHeader { - let files: Vec = parse_compose_file(file_ptr).into_iter().collect(); - let service = match string_from_header(service_ptr) { - Some(s) => s, - None => return json_err("service name is required"), - }; - let cmd: Vec = string_from_header(cmd_ptr) - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); - - match make_engine(files) { - Err(e) => json_err(&e), - Ok(engine) => match block(engine.exec(&service, &cmd, None, None)) { - Err(e) => json_err(&e.to_string()), - Ok(result) => { - let stdout = result.stdout.replace('"', "\\\"").replace('\n', "\\n"); - let stderr = result.stderr.replace('"', "\\\"").replace('\n', "\\n"); - let payload = format!("{{\"stdout\":\"{}\",\"stderr\":\"{}\"}}", stdout, stderr); - json_ok(&payload) - } - }, - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_compose_config(file_ptr: *const StringHeader) -> *const StringHeader { - let files: Vec = parse_compose_file(file_ptr).into_iter().collect(); - let config = crate::config::ProjectConfig::new(files, None, Vec::new()); - match crate::project::ComposeProject::load(&config) { - Err(e) => json_err(&e.to_string()), - Ok(proj) => { - let yaml = proj.spec.to_yaml().unwrap_or_default(); - let escaped = yaml.replace('"', "\\\"").replace('\n', "\\n"); - json_ok(&format!("\"{}\"", escaped)) - } - } -} - -#[cfg(test)] -mod tests { - use super::parse_services_filter; - - #[test] - fn services_filter_accepts_absent_and_empty_values() { - assert_eq!(parse_services_filter(None), Ok(Vec::new())); - assert_eq!( - parse_services_filter(Some(" ".to_string())), - Ok(Vec::new()) - ); - } - - #[test] - fn services_filter_accepts_a_string_array() { - assert_eq!( - parse_services_filter(Some(r#"["api","db"]"#.to_string())), - Ok(vec!["api".to_string(), "db".to_string()]) - ); - } - - #[test] - fn services_filter_rejects_invalid_or_non_array_json() { - assert_eq!( - parse_services_filter(Some("not-json".to_string())), - Err("services must be a JSON array") - ); - assert_eq!( - parse_services_filter(Some(r#"{"service":"api"}"#.to_string())), - Err("services must be a JSON array") - ); - } -} diff --git a/crates/perry-container-compose/src/lib.rs b/crates/perry-container-compose/src/lib.rs index bde3568cba..94ed0ee6eb 100644 --- a/crates/perry-container-compose/src/lib.rs +++ b/crates/perry-container-compose/src/lib.rs @@ -24,14 +24,6 @@ pub mod yaml; #[cfg(any(test, feature = "test-utils"))] pub mod testing; -// FFI exports (Perry TypeScript integration). NOTE: when this crate is -// consumed by perry-stdlib (the canonical FFI host), the `ffi` feature -// must NOT be enabled — perry-stdlib publishes a different (canonical -// SPEC §9.1, stack-handle based) `js_compose_*` shape that would collide -// at link with this module's legacy YAML-file-path shape. -#[cfg(feature = "ffi")] -pub mod ffi; - // Re-exports pub use backend::{ detect_backend, platform_candidates, probe_all_candidates, AppleContainerProtocol, diff --git a/crates/perry-container-compose/tests/legacy_ffi_removed.rs b/crates/perry-container-compose/tests/legacy_ffi_removed.rs new file mode 100644 index 0000000000..d8731b7696 --- /dev/null +++ b/crates/perry-container-compose/tests/legacy_ffi_removed.rs @@ -0,0 +1,35 @@ +use std::path::Path; + +#[test] +fn legacy_ffi_surface_stays_removed() { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest = std::fs::read_to_string(crate_dir.join("Cargo.toml")) + .expect("read perry-container-compose Cargo.toml"); + let features = manifest + .split_once("[features]") + .expect("manifest has a features table") + .1 + .split("\n[") + .next() + .expect("features table has contents"); + + assert!( + !features.lines().any(|line| { + line.split('#') + .next() + .is_some_and(|entry| entry.trim_start().starts_with("ffi =")) + }), + "the legacy `ffi` feature must not be reintroduced; perry-stdlib owns the canonical compose FFI" + ); + + let lib = std::fs::read_to_string(crate_dir.join("src/lib.rs")) + .expect("read perry-container-compose src/lib.rs"); + assert!( + !lib.contains("feature = \"ffi\""), + "src/lib.rs must not gate a module on the removed legacy `ffi` feature" + ); + assert!( + !crate_dir.join("src/ffi.rs").exists(), + "the duplicate legacy compose FFI module must stay deleted" + ); +} diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 1877c841c9..ffcd946aad 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -339,11 +339,6 @@ perry-updater = { workspace = true } # entry is still required for `cargo build -p perry-container-compose` # to succeed — that's enforced by the `crate_in_workspace_members` test # in this crate's tests/. -# -# NOTE 2: do NOT enable the crate's own `ffi` feature here — it exports -# a *different* legacy `js_compose_*` shape (YAML-file-path-based) that -# would collide with stdlib's canonical SPEC §9.1 stack-handle -# signatures at link. perry-container-compose = { path = "../perry-container-compose", optional = true } thiserror.workspace = true