Skip to content

Latest commit

 

History

History
1594 lines (1273 loc) · 38.9 KB

File metadata and controls

1594 lines (1273 loc) · 38.9 KB

FFI Usage Guide for JUCE-RS

Table of Contents

  1. Introduction
  2. FFI Architecture Overview
  3. Core FFI Patterns
  4. Memory Management
  5. Error Handling
  6. Type Conversions
  7. Safety Considerations
  8. Best Practices
  9. Common Pitfalls
  10. Testing FFI Code
  11. Examples

Introduction

This guide documents the Foreign Function Interface (FFI) patterns and best practices used in JUCE-RS. The FFI layer enables interoperability between Rust and C++ code, primarily for testing the Rust implementation against the original JUCE C++ framework.

Purpose of FFI in JUCE-RS

  • Correctness Testing: Compare Rust implementations against C++ JUCE behavior
  • Gradual Migration: Allow incremental porting of C++ code to Rust
  • Platform Integration: Interface with platform-specific C/C++ APIs
  • Plugin Hosting: Load and interact with native audio plugins

Key Design Principles

  1. Safety First: All FFI boundaries are carefully validated
  2. Zero-Cost Abstraction: Minimal overhead for FFI calls
  3. Memory Safety: Proper ownership tracking across language boundaries
  4. Error Propagation: Consistent error handling between Rust and C++
  5. Type Safety: Strong typing even across FFI boundaries

FFI Architecture Overview

Three-Layer Architecture

┌─────────────────────────────────────────────────────────┐
│                   Rust Public API                        │
│              (Safe, idiomatic Rust)                     │
└─────────────────────────────────────────────────────────┘
                          │
┌─────────────────────────────────────────────────────────┐
│                  FFI Bridge Layer                        │
│  ┌────────────────────────────────────────────────┐    │
│  │  Opaque Pointers & C-Compatible Types          │    │
│  │  - Memory management                           │    │
│  │  - Error handling                              │    │
│  │  - Type conversions                            │    │
│  └────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘
                          │
┌─────────────────────────────────────────────────────────┐
│              C++ JUCE Implementation                     │
│                (For testing only)                       │
└─────────────────────────────────────────────────────────┘

Module Organization

Each JUCE module has a corresponding FFI crate:

juce-core          → juce-core-ffi
juce-audio-basics  → juce-audio-basics-ffi
juce-graphics      → juce-graphics-ffi
...

The juce-ffi-common crate provides shared FFI infrastructure used by all module FFI crates.

Core FFI Patterns

Pattern 1: Opaque Pointers

Opaque pointers hide Rust implementation details from C/C++ code while maintaining type safety.

Definition:

// In juce-ffi-common/src/opaque.rs
#[repr(C)]
pub struct OpaqueJString {
    _private: [u8; 0],  // Zero-sized, prevents instantiation
}

Usage:

// Rust side - converting to opaque pointer
#[no_mangle]
pub extern "C" fn jstring_new(text: *const c_char) -> *mut OpaqueJString {
    let rust_string = unsafe { 
        CStr::from_ptr(text).to_string_lossy().into_owned() 
    };
    let jstring = JString::new(&rust_string);
    Box::into_raw(Box::new(jstring)) as *mut OpaqueJString
}

// Rust side - using opaque pointer
#[no_mangle]
pub extern "C" fn jstring_length(ptr: *const OpaqueJString) -> usize {
    let jstring = unsafe { &*(ptr as *const JString) };
    jstring.len()
}

// Rust side - freeing opaque pointer
#[no_mangle]
pub extern "C" fn jstring_free(ptr: *mut OpaqueJString) {
    if !ptr.is_null() {
        unsafe {
            let _ = Box::from_raw(ptr as *mut JString);
        }
    }
}

C++ side:

// Forward declaration
struct OpaqueJString;

// Usage
extern "C" {
    OpaqueJString* jstring_new(const char* text);
    size_t jstring_length(const OpaqueJString* ptr);
    void jstring_free(OpaqueJString* ptr);
}

void example() {
    OpaqueJString* str = jstring_new("Hello");
    size_t len = jstring_length(str);
    jstring_free(str);
}

Pattern 2: C-Compatible Structs

For simple data types, use C-compatible struct representations.

Definition:

#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CRectangle {
    pub x: f32,
    pub y: f32,
    pub width: f32,
    pub height: f32,
}

Conversion Traits:

pub trait ToCRepr {
    type CType;
    fn to_c(&self) -> Self::CType;
}

pub trait FromCRepr {
    type CType;
    fn from_c(c: Self::CType) -> Self;
}

impl ToCRepr for Rectangle {
    type CType = CRectangle;
    
    fn to_c(&self) -> Self::CType {
        CRectangle {
            x: self.x,
            y: self.y,
            width: self.width,
            height: self.height,
        }
    }
}

impl FromCRepr for Rectangle {
    type CType = CRectangle;
    
    fn from_c(c: Self::CType) -> Self {
        Rectangle {
            x: c.x,
            y: c.y,
            width: c.width,
            height: c.height,
        }
    }
}

Pattern 3: FfiBox for Safe Allocation

FfiBox provides automatic memory tracking and cleanup.

use juce_ffi_common::FfiBox;

#[no_mangle]
pub extern "C" fn create_audio_buffer(
    channels: usize,
    samples: usize
) -> *mut OpaqueAudioBuffer {
    let buffer = AudioBuffer::<f32>::new(channels, samples);
    FfiBox::new(buffer).into_raw() as *mut OpaqueAudioBuffer
}

#[no_mangle]
pub extern "C" fn free_audio_buffer(ptr: *mut OpaqueAudioBuffer) {
    if !ptr.is_null() {
        unsafe {
            let _ = FfiBox::<AudioBuffer<f32>>::from_raw(ptr as *mut AudioBuffer<f32>);
        }
    }
}

Memory Management

Ownership Rules

  1. Rust Owns: Objects created in Rust are owned by Rust
  2. Caller Frees: The caller (C++ or Rust) that allocates must free
  3. No Shared Ownership: Avoid shared ownership across FFI boundaries
  4. Explicit Cleanup: Always provide explicit free functions

Memory Tracking

JUCE-RS includes a global memory manager for tracking FFI allocations:

use juce_ffi_common::{register_global_allocation, unregister_global_allocation};

#[no_mangle]
pub extern "C" fn create_object() -> *mut OpaqueObject {
    let obj = Object::new();
    let ptr = Box::into_raw(Box::new(obj));
    register_global_allocation(ptr);
    ptr as *mut OpaqueObject
}

#[no_mangle]
pub extern "C" fn free_object(ptr: *mut OpaqueObject) {
    if !ptr.is_null() {
        let typed_ptr = ptr as *mut Object;
        if unregister_global_allocation(typed_ptr) {
            unsafe {
                let _ = Box::from_raw(typed_ptr);
            }
        }
    }
}

Checking for Memory Leaks

use juce_ffi_common::{get_global_memory_stats, check_global_leaks};

#[test]
fn test_no_memory_leaks() {
    let (initial_allocs, _) = get_global_memory_stats();
    
    // Perform operations
    let ptr = create_object();
    free_object(ptr);
    
    let (final_allocs, _) = get_global_memory_stats();
    assert_eq!(initial_allocs, final_allocs);
    
    let leaks = check_global_leaks();
    assert!(leaks.is_empty(), "Memory leaks detected: {:?}", leaks);
}

Lifetime Management

Short-lived objects:

#[no_mangle]
pub extern "C" fn process_string(input: *const c_char) -> *mut c_char {
    let rust_str = unsafe { CStr::from_ptr(input).to_string_lossy() };
    let result = rust_str.to_uppercase();
    string_to_c(&result).unwrap_or(std::ptr::null_mut())
}

Long-lived objects:

#[no_mangle]
pub extern "C" fn audio_processor_new() -> *mut OpaqueAudioProcessor {
    let processor = AudioProcessor::new();
    FfiBox::new(processor).into_raw() as *mut OpaqueAudioProcessor
}

#[no_mangle]
pub extern "C" fn audio_processor_free(ptr: *mut OpaqueAudioProcessor) {
    if !ptr.is_null() {
        unsafe {
            let _ = FfiBox::<AudioProcessor>::from_raw(ptr as *mut AudioProcessor);
        }
    }
}

Error Handling

Error Code System

JUCE-RS uses a standardized error code system:

#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CErrorCode {
    Success = 0,
    InvalidParameter = 1,
    OutOfMemory = 2,
    IoError = 3,
    StringConversion = 4,
    Threading = 5,
    Serialization = 6,
    NotSupported = 7,
    GenericError = 99,
}

Error Handling Pattern

Rust side:

use juce_ffi_common::{CErrorCode, set_last_error, clear_last_error};

#[no_mangle]
pub extern "C" fn file_read(
    path: *const c_char,
    buffer: *mut u8,
    size: usize
) -> CErrorCode {
    // Validate parameters
    if path.is_null() || buffer.is_null() {
        set_last_error("Null pointer parameter".to_string());
        return CErrorCode::InvalidParameter;
    }
    
    // Convert path
    let path_str = match unsafe { CStr::from_ptr(path).to_str() } {
        Ok(s) => s,
        Err(e) => {
            set_last_error(format!("Invalid UTF-8: {}", e));
            return CErrorCode::StringConversion;
        }
    };
    
    // Perform operation
    match std::fs::read(path_str) {
        Ok(data) => {
            let copy_size = data.len().min(size);
            unsafe {
                std::ptr::copy_nonoverlapping(data.as_ptr(), buffer, copy_size);
            }
            clear_last_error();
            CErrorCode::Success
        }
        Err(e) => {
            set_last_error(format!("IO error: {}", e));
            CErrorCode::IoError
        }
    }
}

C++ side:

extern "C" {
    CErrorCode file_read(const char* path, uint8_t* buffer, size_t size);
    CErrorCode juce_rs_get_last_error_message(char* buffer, size_t size);
}

void example() {
    uint8_t buffer[1024];
    CErrorCode result = file_read("test.txt", buffer, sizeof(buffer));
    
    if (result != CErrorCode::Success) {
        char error_msg[256];
        juce_rs_get_last_error_message(error_msg, sizeof(error_msg));
        std::cerr << "Error: " << error_msg << std::endl;
    }
}

Using Error Handling Macros

use juce_ffi_common::{ffi_try, ffi_try_ptr, ffi_try_default};

#[no_mangle]
pub extern "C" fn parse_json(json_str: *const c_char) -> *mut OpaqueJsonValue {
    let rust_str = ffi_try_ptr!(unsafe { 
        CStr::from_ptr(json_str).to_str().map_err(|e| 
            juce_core::Error::StringConversion(e.to_string())
        )
    });
    
    let json_value = ffi_try_ptr!(
        serde_json::from_str(rust_str).map_err(|e| 
            juce_core::Error::Serialization(e.to_string())
        )
    );
    
    FfiBox::new(json_value).into_raw() as *mut OpaqueJsonValue
}

Type Conversions

String Conversions

Rust to C:

use juce_ffi_common::string_to_c;

#[no_mangle]
pub extern "C" fn get_version_string() -> *mut c_char {
    let version = "1.0.0";
    string_to_c(version).unwrap_or(std::ptr::null_mut())
}

C to Rust:

use juce_ffi_common::string_from_c;

#[no_mangle]
pub extern "C" fn set_name(name: *const c_char) -> CErrorCode {
    if name.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let rust_name = match unsafe { string_from_c(name) } {
        Ok(s) => s,
        Err(e) => {
            set_last_error(format!("String conversion failed: {}", e));
            return CErrorCode::StringConversion;
        }
    };
    
    // Use rust_name...
    CErrorCode::Success
}

Cleanup:

use juce_ffi_common::free_c_string;

// C++ side must call this
#[no_mangle]
pub extern "C" fn free_string(ptr: *mut c_char) {
    unsafe { free_c_string(ptr); }
}

Numeric Type Conversions

Boolean:

use juce_ffi_common::{CBool, bool_to_c, bool_from_c};

#[no_mangle]
pub extern "C" fn is_valid(ptr: *const OpaqueObject) -> CBool {
    if ptr.is_null() {
        return bool_to_c(false);
    }
    let obj = unsafe { &*(ptr as *const Object) };
    bool_to_c(obj.is_valid())
}

#[no_mangle]
pub extern "C" fn set_enabled(ptr: *mut OpaqueObject, enabled: CBool) {
    if !ptr.is_null() {
        let obj = unsafe { &mut *(ptr as *mut Object) };
        obj.set_enabled(bool_from_c(enabled));
    }
}

Floating Point:

// f32 and f64 are directly compatible with C
#[no_mangle]
pub extern "C" fn calculate_gain(input: f32, factor: f32) -> f32 {
    input * factor
}

Array/Buffer Conversions

Passing arrays from C to Rust:

#[no_mangle]
pub extern "C" fn process_samples(
    samples: *const f32,
    num_samples: usize,
    output: *mut f32
) -> CErrorCode {
    if samples.is_null() || output.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let input_slice = unsafe { std::slice::from_raw_parts(samples, num_samples) };
    let output_slice = unsafe { std::slice::from_raw_parts_mut(output, num_samples) };
    
    for (i, &sample) in input_slice.iter().enumerate() {
        output_slice[i] = sample * 2.0; // Example processing
    }
    
    CErrorCode::Success
}

Returning arrays from Rust to C:

#[no_mangle]
pub extern "C" fn generate_samples(
    num_samples: usize,
    output: *mut f32
) -> CErrorCode {
    if output.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let output_slice = unsafe { std::slice::from_raw_parts_mut(output, num_samples) };
    
    for (i, sample) in output_slice.iter_mut().enumerate() {
        *sample = (i as f32 * 0.1).sin();
    }
    
    CErrorCode::Success
}

Struct Conversions

Using ToCRepr/FromCRepr:

#[no_mangle]
pub extern "C" fn get_bounds(ptr: *const OpaqueComponent) -> CRectangle {
    if ptr.is_null() {
        return CRectangle { x: 0.0, y: 0.0, width: 0.0, height: 0.0 };
    }
    
    let component = unsafe { &*(ptr as *const Component) };
    component.bounds().to_c()
}

#[no_mangle]
pub extern "C" fn set_bounds(ptr: *mut OpaqueComponent, bounds: CRectangle) {
    if !ptr.is_null() {
        let component = unsafe { &mut *(ptr as *mut Component) };
        component.set_bounds(Rectangle::from_c(bounds));
    }
}

Safety Considerations

Critical Safety Rules

  1. Always Validate Pointers: Check for null before dereferencing
  2. Validate Array Bounds: Ensure indices are within valid ranges
  3. Check String Encoding: Validate UTF-8 when converting from C
  4. Prevent Data Races: Use proper synchronization for shared data
  5. Document Unsafe: Clearly document safety requirements

Pointer Validation

#[no_mangle]
pub extern "C" fn safe_operation(ptr: *mut OpaqueObject) -> CErrorCode {
    // Step 1: Null check
    if ptr.is_null() {
        set_last_error("Null pointer passed".to_string());
        return CErrorCode::InvalidParameter;
    }
    
    // Step 2: Cast and dereference (unsafe block)
    let obj = unsafe { &mut *(ptr as *mut Object) };
    
    // Step 3: Validate object state
    if !obj.is_initialized() {
        set_last_error("Object not initialized".to_string());
        return CErrorCode::InvalidParameter;
    }
    
    // Step 4: Perform operation
    obj.do_something();
    
    CErrorCode::Success
}

Thread Safety

Atomic Operations:

use std::sync::atomic::{AtomicBool, Ordering};

pub struct ThreadSafeObject {
    is_processing: AtomicBool,
    // ... other fields
}

#[no_mangle]
pub extern "C" fn start_processing(ptr: *mut OpaqueObject) -> CErrorCode {
    if ptr.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let obj = unsafe { &*(ptr as *const ThreadSafeObject) };
    
    // Atomic compare-and-swap
    if obj.is_processing.compare_exchange(
        false, true, Ordering::SeqCst, Ordering::SeqCst
    ).is_err() {
        set_last_error("Already processing".to_string());
        return CErrorCode::GenericError;
    }
    
    CErrorCode::Success
}

Mutex Protection:

use std::sync::Mutex;

pub struct ProtectedObject {
    data: Mutex<Vec<f32>>,
}

#[no_mangle]
pub extern "C" fn add_sample(ptr: *mut OpaqueObject, sample: f32) -> CErrorCode {
    if ptr.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let obj = unsafe { &*(ptr as *const ProtectedObject) };
    
    match obj.data.lock() {
        Ok(mut data) => {
            data.push(sample);
            CErrorCode::Success
        }
        Err(e) => {
            set_last_error(format!("Lock failed: {}", e));
            CErrorCode::Threading
        }
    }
}

Preventing Undefined Behavior

DO:

  • ✅ Always check for null pointers
  • ✅ Validate array indices and sizes
  • ✅ Use #[deny(unsafe_op_in_unsafe_fn)]
  • ✅ Document safety requirements
  • ✅ Use NonNull<T> when pointer must not be null
  • ✅ Validate UTF-8 encoding for strings

DON'T:

  • ❌ Assume pointers are valid
  • ❌ Dereference without null checks
  • ✅ Create references with unbounded lifetimes
  • ❌ Share mutable state without synchronization
  • ❌ Panic in FFI functions
  • ❌ Leak memory

Unsafe Code Documentation

/// # Safety
/// 
/// This function is unsafe because:
/// - `ptr` must be a valid pointer to an initialized `Object`
/// - `ptr` must not be accessed concurrently from other threads
/// - The caller must ensure `ptr` remains valid for the duration of this call
#[no_mangle]
pub unsafe extern "C" fn unsafe_operation(ptr: *mut OpaqueObject) -> CErrorCode {
    // Implementation with unsafe operations
    if ptr.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    // SAFETY: We've checked for null, and the caller guarantees validity
    let obj = unsafe { &mut *ptr };
    
    // ... rest of implementation
    CErrorCode::Success
}

Best Practices

1. Consistent Naming Conventions

Function Names:

  • Constructor: <type>_new or <type>_create
  • Destructor: <type>_free or <type>_destroy
  • Getter: <type>_get_<property>
  • Setter: <type>_set_<property>
  • Method: <type>_<action>

Example:

#[no_mangle]
pub extern "C" fn audio_buffer_new(channels: usize, samples: usize) -> *mut OpaqueAudioBuffer;

#[no_mangle]
pub extern "C" fn audio_buffer_get_num_channels(ptr: *const OpaqueAudioBuffer) -> usize;

#[no_mangle]
pub extern "C" fn audio_buffer_set_sample(ptr: *mut OpaqueAudioBuffer, channel: usize, index: usize, value: f32);

#[no_mangle]
pub extern "C" fn audio_buffer_clear(ptr: *mut OpaqueAudioBuffer);

#[no_mangle]
pub extern "C" fn audio_buffer_free(ptr: *mut OpaqueAudioBuffer);

2. Always Provide Free Functions

Every allocation function must have a corresponding free function:

// ✅ Good
#[no_mangle]
pub extern "C" fn object_new() -> *mut OpaqueObject { /* ... */ }

#[no_mangle]
pub extern "C" fn object_free(ptr: *mut OpaqueObject) { /* ... */ }

// ❌ Bad - no free function
#[no_mangle]
pub extern "C" fn object_new() -> *mut OpaqueObject { /* ... */ }

3. Use Opaque Pointers for Complex Types

// ✅ Good - opaque pointer
#[no_mangle]
pub extern "C" fn processor_new() -> *mut OpaqueAudioProcessor;

// ❌ Bad - exposing internal structure
#[repr(C)]
pub struct AudioProcessor {
    pub internal_state: *mut InternalState,
    pub buffer: *mut f32,
    // ...
}

4. Validate All Inputs

#[no_mangle]
pub extern "C" fn process_audio(
    ptr: *mut OpaqueProcessor,
    input: *const f32,
    output: *mut f32,
    num_samples: usize
) -> CErrorCode {
    // Validate all parameters
    if ptr.is_null() || input.is_null() || output.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    if num_samples == 0 || num_samples > MAX_SAMPLES {
        set_last_error(format!("Invalid sample count: {}", num_samples));
        return CErrorCode::InvalidParameter;
    }
    
    // ... rest of implementation
}

5. Return Error Codes, Not Panics

// ✅ Good - returns error code
#[no_mangle]
pub extern "C" fn divide(a: f32, b: f32, result: *mut f32) -> CErrorCode {
    if result.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    if b == 0.0 {
        set_last_error("Division by zero".to_string());
        return CErrorCode::InvalidParameter;
    }
    
    unsafe { *result = a / b; }
    CErrorCode::Success
}

// ❌ Bad - can panic
#[no_mangle]
pub extern "C" fn divide_bad(a: f32, b: f32) -> f32 {
    assert!(b != 0.0, "Division by zero"); // DON'T PANIC IN FFI!
    a / b
}

6. Use #[no_mangle] and extern "C"

// ✅ Good
#[no_mangle]
pub extern "C" fn my_function() { }

// ❌ Bad - will be name-mangled
pub fn my_function() { }

// ❌ Bad - wrong calling convention
#[no_mangle]
pub fn my_function() { }

7. Document FFI Functions

/// Create a new audio buffer
/// 
/// # Parameters
/// - `channels`: Number of audio channels (must be > 0)
/// - `samples`: Number of samples per channel (must be > 0)
/// 
/// # Returns
/// Pointer to the new audio buffer, or null on failure
/// 
/// # Safety
/// The returned pointer must be freed with `audio_buffer_free`
/// 
/// # Example
/// ```c
/// OpaqueAudioBuffer* buffer = audio_buffer_new(2, 512);
/// if (buffer != NULL) {
///     // Use buffer...
///     audio_buffer_free(buffer);
/// }
/// ```
#[no_mangle]
pub extern "C" fn audio_buffer_new(
    channels: usize,
    samples: usize
) -> *mut OpaqueAudioBuffer {
    if channels == 0 || samples == 0 {
        set_last_error("Invalid buffer dimensions".to_string());
        return std::ptr::null_mut();
    }
    
    let buffer = AudioBuffer::<f32>::new(channels, samples);
    FfiBox::new(buffer).into_raw() as *mut OpaqueAudioBuffer
}

8. Use Type Aliases for Clarity

pub type AudioSample = f32;
pub type SampleRate = f64;
pub type ChannelIndex = usize;

#[no_mangle]
pub extern "C" fn process_channel(
    ptr: *mut OpaqueProcessor,
    channel: ChannelIndex,
    samples: *mut AudioSample,
    num_samples: usize,
    sample_rate: SampleRate
) -> CErrorCode {
    // Implementation
}

Common Pitfalls

Pitfall 1: Forgetting to Free Memory

// ❌ Bad - memory leak
void bad_example() {
    OpaqueString* str = jstring_new("Hello");
    // Forgot to call jstring_free(str)!
}

// ✅ Good - proper cleanup
void good_example() {
    OpaqueString* str = jstring_new("Hello");
    // Use str...
    jstring_free(str);
}

Pitfall 2: Use After Free

// ❌ Bad - use after free
void bad_example() {
    OpaqueString* str = jstring_new("Hello");
    jstring_free(str);
    size_t len = jstring_length(str); // UNDEFINED BEHAVIOR!
}

// ✅ Good - don't use after free
void good_example() {
    OpaqueString* str = jstring_new("Hello");
    size_t len = jstring_length(str);
    jstring_free(str);
    // Don't use str after this point
}

Pitfall 3: Double Free

// ❌ Bad - double free
void bad_example() {
    OpaqueString* str = jstring_new("Hello");
    jstring_free(str);
    jstring_free(str); // UNDEFINED BEHAVIOR!
}

// ✅ Good - set to null after free
void good_example() {
    OpaqueString* str = jstring_new("Hello");
    jstring_free(str);
    str = NULL; // Prevent accidental reuse
}

Pitfall 4: Buffer Overruns

// ❌ Bad - buffer overrun
void bad_example() {
    float buffer[512];
    process_samples(input, 1024, buffer); // Writing 1024 samples to 512-element buffer!
}

// ✅ Good - correct size
void good_example() {
    float buffer[512];
    process_samples(input, 512, buffer);
}

Pitfall 5: Ignoring Error Codes

// ❌ Bad - ignoring errors
void bad_example() {
    file_read("test.txt", buffer, size); // Ignoring return value
}

// ✅ Good - checking errors
void good_example() {
    CErrorCode result = file_read("test.txt", buffer, size);
    if (result != CErrorCode::Success) {
        char error_msg[256];
        juce_rs_get_last_error_message(error_msg, sizeof(error_msg));
        handle_error(error_msg);
    }
}

Pitfall 6: Incorrect String Handling

// ❌ Bad - not freeing returned string
void bad_example() {
    char* version = get_version_string();
    printf("%s\n", version);
    // Memory leak - forgot to free!
}

// ✅ Good - proper string cleanup
void good_example() {
    char* version = get_version_string();
    if (version != NULL) {
        printf("%s\n", version);
        free_string(version);
    }
}

Pitfall 7: Thread Safety Violations

// ❌ Bad - concurrent access without synchronization
void thread1() {
    set_value(obj, 42);
}

void thread2() {
    int val = get_value(obj); // Data race!
}

// ✅ Good - proper synchronization
void thread1() {
    lock_mutex(obj_mutex);
    set_value(obj, 42);
    unlock_mutex(obj_mutex);
}

void thread2() {
    lock_mutex(obj_mutex);
    int val = get_value(obj);
    unlock_mutex(obj_mutex);
}

Pitfall 8: Lifetime Issues

// ❌ Bad - returning pointer to stack memory
#[no_mangle]
pub extern "C" fn get_name() -> *const c_char {
    let name = "JUCE-RS";
    name.as_ptr() as *const c_char // DANGLING POINTER!
}

// ✅ Good - heap allocation
#[no_mangle]
pub extern "C" fn get_name() -> *mut c_char {
    let name = "JUCE-RS";
    string_to_c(name).unwrap_or(std::ptr::null_mut())
}

Testing FFI Code

Unit Testing FFI Functions

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_string_creation_and_cleanup() {
        let c_str = b"Hello, World!\0";
        let ptr = jstring_new(c_str.as_ptr() as *const c_char);
        
        assert!(!ptr.is_null());
        
        let len = jstring_length(ptr);
        assert_eq!(len, 13);
        
        jstring_free(ptr);
    }
    
    #[test]
    fn test_null_pointer_handling() {
        let len = jstring_length(std::ptr::null());
        assert_eq!(len, 0); // Should handle null gracefully
    }
}

Property-Based Testing

use proptest::prelude::*;

proptest! {
    #[test]
    fn prop_string_round_trip(s in "\\PC*") {
        let c_str = string_to_c(&s).unwrap();
        let recovered = unsafe { string_from_c(c_str).unwrap() };
        unsafe { free_c_string(c_str); }
        
        assert_eq!(s, recovered);
    }
    
    #[test]
    fn prop_buffer_operations(
        channels in 1usize..8,
        samples in 1usize..1024
    ) {
        let ptr = audio_buffer_new(channels, samples);
        assert!(!ptr.is_null());
        
        let num_channels = audio_buffer_get_num_channels(ptr);
        assert_eq!(num_channels, channels);
        
        let num_samples = audio_buffer_get_num_samples(ptr);
        assert_eq!(num_samples, samples);
        
        audio_buffer_free(ptr);
    }
}

Memory Leak Testing

#[test]
fn test_no_memory_leaks() {
    use juce_ffi_common::{get_global_memory_stats, check_global_leaks};
    
    let (initial_allocs, _) = get_global_memory_stats();
    
    // Perform many allocations and deallocations
    for _ in 0..1000 {
        let ptr = audio_buffer_new(2, 512);
        audio_buffer_free(ptr);
    }
    
    let (final_allocs, _) = get_global_memory_stats();
    assert_eq!(initial_allocs, final_allocs, "Memory leak detected");
    
    let leaks = check_global_leaks();
    assert!(leaks.is_empty(), "Leaks found: {:?}", leaks);
}

Comparison Testing (Rust vs C++)

#[test]
fn test_rust_cpp_equivalence() {
    // Create objects in both Rust and C++
    let rust_ptr = rust_audio_buffer_new(2, 512);
    let cpp_ptr = unsafe { cpp_audio_buffer_new(2, 512) };
    
    // Perform same operations
    rust_audio_buffer_clear(rust_ptr);
    unsafe { cpp_audio_buffer_clear(cpp_ptr); }
    
    // Compare results
    for channel in 0..2 {
        for sample in 0..512 {
            let rust_val = rust_audio_buffer_get_sample(rust_ptr, channel, sample);
            let cpp_val = unsafe { cpp_audio_buffer_get_sample(cpp_ptr, channel, sample) };
            assert_eq!(rust_val, cpp_val);
        }
    }
    
    // Cleanup
    rust_audio_buffer_free(rust_ptr);
    unsafe { cpp_audio_buffer_free(cpp_ptr); }
}

Fuzzing FFI Boundaries

#[cfg(fuzzing)]
pub fn fuzz_parse_json(data: &[u8]) {
    if let Ok(s) = std::str::from_utf8(data) {
        let c_str = string_to_c(s).unwrap_or(std::ptr::null_mut());
        if !c_str.is_null() {
            let ptr = parse_json(c_str);
            if !ptr.is_null() {
                json_value_free(ptr);
            }
            unsafe { free_c_string(c_str); }
        }
    }
}

Examples

Example 1: Simple String API

Rust Implementation:

use juce_ffi_common::*;
use std::ffi::{CStr, c_char};

#[repr(C)]
pub struct OpaqueJString {
    _private: [u8; 0],
}

#[no_mangle]
pub extern "C" fn jstring_new(text: *const c_char) -> *mut OpaqueJString {
    if text.is_null() {
        set_last_error("Null text pointer".to_string());
        return std::ptr::null_mut();
    }
    
    let rust_str = match unsafe { CStr::from_ptr(text).to_str() } {
        Ok(s) => s,
        Err(e) => {
            set_last_error(format!("Invalid UTF-8: {}", e));
            return std::ptr::null_mut();
        }
    };
    
    let jstring = juce_core::JString::new(rust_str);
    FfiBox::new(jstring).into_raw() as *mut OpaqueJString
}

#[no_mangle]
pub extern "C" fn jstring_length(ptr: *const OpaqueJString) -> usize {
    if ptr.is_null() {
        return 0;
    }
    
    let jstring = unsafe { &*(ptr as *const juce_core::JString) };
    jstring.len()
}

#[no_mangle]
pub extern "C" fn jstring_to_uppercase(ptr: *const OpaqueJString) -> *mut OpaqueJString {
    if ptr.is_null() {
        set_last_error("Null pointer".to_string());
        return std::ptr::null_mut();
    }
    
    let jstring = unsafe { &*(ptr as *const juce_core::JString) };
    let upper = jstring.to_uppercase();
    FfiBox::new(upper).into_raw() as *mut OpaqueJString
}

#[no_mangle]
pub extern "C" fn jstring_free(ptr: *mut OpaqueJString) {
    if !ptr.is_null() {
        unsafe {
            let _ = FfiBox::<juce_core::JString>::from_raw(ptr as *mut juce_core::JString);
        }
    }
}

C++ Usage:

#include <iostream>
#include <cassert>

// Forward declarations
struct OpaqueJString;

extern "C" {
    OpaqueJString* jstring_new(const char* text);
    size_t jstring_length(const OpaqueJString* ptr);
    OpaqueJString* jstring_to_uppercase(const OpaqueJString* ptr);
    void jstring_free(OpaqueJString* ptr);
}

int main() {
    // Create string
    OpaqueJString* str = jstring_new("hello world");
    assert(str != nullptr);
    
    // Get length
    size_t len = jstring_length(str);
    assert(len == 11);
    
    // Convert to uppercase
    OpaqueJString* upper = jstring_to_uppercase(str);
    assert(upper != nullptr);
    
    // Cleanup
    jstring_free(str);
    jstring_free(upper);
    
    return 0;
}

Example 2: Audio Buffer Processing

Rust Implementation:

use juce_ffi_common::*;
use juce_audio_basics::AudioBuffer;

#[repr(C)]
pub struct OpaqueAudioBuffer {
    _private: [u8; 0],
}

#[no_mangle]
pub extern "C" fn audio_buffer_new(
    channels: usize,
    samples: usize
) -> *mut OpaqueAudioBuffer {
    if channels == 0 || samples == 0 {
        set_last_error("Invalid buffer dimensions".to_string());
        return std::ptr::null_mut();
    }
    
    let buffer = AudioBuffer::<f32>::new(channels, samples);
    FfiBox::new(buffer).into_raw() as *mut OpaqueAudioBuffer
}

#[no_mangle]
pub extern "C" fn audio_buffer_get_num_channels(
    ptr: *const OpaqueAudioBuffer
) -> usize {
    if ptr.is_null() {
        return 0;
    }
    
    let buffer = unsafe { &*(ptr as *const AudioBuffer<f32>) };
    buffer.num_channels()
}

#[no_mangle]
pub extern "C" fn audio_buffer_get_num_samples(
    ptr: *const OpaqueAudioBuffer
) -> usize {
    if ptr.is_null() {
        return 0;
    }
    
    let buffer = unsafe { &*(ptr as *const AudioBuffer<f32>) };
    buffer.num_samples()
}

#[no_mangle]
pub extern "C" fn audio_buffer_clear(ptr: *mut OpaqueAudioBuffer) -> CErrorCode {
    if ptr.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let buffer = unsafe { &mut *(ptr as *mut AudioBuffer<f32>) };
    buffer.clear();
    CErrorCode::Success
}

#[no_mangle]
pub extern "C" fn audio_buffer_apply_gain(
    ptr: *mut OpaqueAudioBuffer,
    gain: f32
) -> CErrorCode {
    if ptr.is_null() {
        return CErrorCode::InvalidParameter;
    }
    
    let buffer = unsafe { &mut *(ptr as *mut AudioBuffer<f32>) };
    buffer.apply_gain(gain);
    CErrorCode::Success
}

#[no_mangle]
pub extern "C" fn audio_buffer_get_read_pointer(
    ptr: *const OpaqueAudioBuffer,
    channel: usize
) -> *const f32 {
    if ptr.is_null() {
        return std::ptr::null();
    }
    
    let buffer = unsafe { &*(ptr as *const AudioBuffer<f32>) };
    
    if channel >= buffer.num_channels() {
        set_last_error(format!("Invalid channel index: {}", channel));
        return std::ptr::null();
    }
    
    buffer.read_pointer(channel).as_ptr()
}

#[no_mangle]
pub extern "C" fn audio_buffer_get_write_pointer(
    ptr: *mut OpaqueAudioBuffer,
    channel: usize
) -> *mut f32 {
    if ptr.is_null() {
        return std::ptr::null_mut();
    }
    
    let buffer = unsafe { &mut *(ptr as *mut AudioBuffer<f32>) };
    
    if channel >= buffer.num_channels() {
        set_last_error(format!("Invalid channel index: {}", channel));
        return std::ptr::null_mut();
    }
    
    buffer.write_pointer(channel).as_mut_ptr()
}

#[no_mangle]
pub extern "C" fn audio_buffer_free(ptr: *mut OpaqueAudioBuffer) {
    if !ptr.is_null() {
        unsafe {
            let _ = FfiBox::<AudioBuffer<f32>>::from_raw(ptr as *mut AudioBuffer<f32>);
        }
    }
}

C++ Usage:

#include <iostream>
#include <cmath>

struct OpaqueAudioBuffer;

extern "C" {
    OpaqueAudioBuffer* audio_buffer_new(size_t channels, size_t samples);
    size_t audio_buffer_get_num_channels(const OpaqueAudioBuffer* ptr);
    size_t audio_buffer_get_num_samples(const OpaqueAudioBuffer* ptr);
    CErrorCode audio_buffer_clear(OpaqueAudioBuffer* ptr);
    CErrorCode audio_buffer_apply_gain(OpaqueAudioBuffer* ptr, float gain);
    const float* audio_buffer_get_read_pointer(const OpaqueAudioBuffer* ptr, size_t channel);
    float* audio_buffer_get_write_pointer(OpaqueAudioBuffer* ptr, size_t channel);
    void audio_buffer_free(OpaqueAudioBuffer* ptr);
}

void process_audio(OpaqueAudioBuffer* buffer) {
    size_t channels = audio_buffer_get_num_channels(buffer);
    size_t samples = audio_buffer_get_num_samples(buffer);
    
    for (size_t ch = 0; ch < channels; ++ch) {
        float* data = audio_buffer_get_write_pointer(buffer, ch);
        
        for (size_t i = 0; i < samples; ++i) {
            // Generate sine wave
            data[i] = std::sin(2.0f * M_PI * i / samples);
        }
    }
    
    // Apply gain
    audio_buffer_apply_gain(buffer, 0.5f);
}

int main() {
    // Create buffer
    OpaqueAudioBuffer* buffer = audio_buffer_new(2, 512);
    
    // Clear buffer
    audio_buffer_clear(buffer);
    
    // Process audio
    process_audio(buffer);
    
    // Cleanup
    audio_buffer_free(buffer);
    
    return 0;
}

Example 3: Error Handling

Rust Implementation:

use juce_ffi_common::*;
use std::fs;

#[no_mangle]
pub extern "C" fn read_file_to_string(
    path: *const c_char,
    output: *mut *mut c_char
) -> CErrorCode {
    // Validate parameters
    if path.is_null() || output.is_null() {
        set_last_error("Null pointer parameter".to_string());
        return CErrorCode::InvalidParameter;
    }
    
    // Convert path
    let path_str = match unsafe { CStr::from_ptr(path).to_str() } {
        Ok(s) => s,
        Err(e) => {
            set_last_error(format!("Invalid UTF-8 in path: {}", e));
            return CErrorCode::StringConversion;
        }
    };
    
    // Read file
    let contents = match fs::read_to_string(path_str) {
        Ok(s) => s,
        Err(e) => {
            set_last_error(format!("Failed to read file: {}", e));
            return CErrorCode::IoError;
        }
    };
    
    // Convert to C string
    match string_to_c(&contents) {
        Ok(c_str) => {
            unsafe { *output = c_str; }
            clear_last_error();
            CErrorCode::Success
        }
        Err(e) => {
            set_last_error(format!("String conversion failed: {}", e));
            CErrorCode::StringConversion
        }
    }
}

C++ Usage:

#include <iostream>
#include <cstring>

extern "C" {
    CErrorCode read_file_to_string(const char* path, char** output);
    CErrorCode juce_rs_get_last_error_message(char* buffer, size_t size);
    void free_string(char* ptr);
}

bool read_file(const char* path, std::string& output) {
    char* contents = nullptr;
    CErrorCode result = read_file_to_string(path, &contents);
    
    if (result != CErrorCode::Success) {
        char error_msg[512];
        juce_rs_get_last_error_message(error_msg, sizeof(error_msg));
        std::cerr << "Error reading file: " << error_msg << std::endl;
        return false;
    }
    
    output = contents;
    free_string(contents);
    return true;
}

int main() {
    std::string contents;
    
    if (read_file("test.txt", contents)) {
        std::cout << "File contents: " << contents << std::endl;
    } else {
        std::cerr << "Failed to read file" << std::endl;
    }
    
    return 0;
}

Conclusion

This guide covers the essential patterns and best practices for working with FFI in JUCE-RS. Key takeaways:

  1. Safety First: Always validate inputs and handle errors properly
  2. Memory Management: Track allocations and ensure proper cleanup
  3. Clear Ownership: Make ownership and lifetime rules explicit
  4. Consistent Patterns: Use consistent naming and API design
  5. Thorough Testing: Test FFI boundaries extensively

For more information:

  • See ffi/juce-ffi-common/src/ for FFI infrastructure implementation
  • See tests/property/tests/ffi_validation_properties.rs for FFI testing examples
  • See individual module FFI crates (e.g., ffi/juce-core-ffi/) for module-specific patterns

Additional Resources