Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 11

Robust Error Handling in FFI

Recommended resources

Hello! Welcome back.

In our last lesson, we built a complete, two-way communication loop between Unity and Rust. We can send JSON commands to the Rust backend and receive JSON responses. While this works for the "happy path," a production-grade game engine must be resilient to unexpected problems. FFI communication is a prime area for such issues.

This lesson directly addresses the learning outcome: Establish a comprehensive error handling strategy for FFI communication. We'll move beyond simple error strings and build a robust system that can gracefully handle even catastrophic failures like panics in the Rust core, preventing them from crashing our entire Unity application.

1. The Danger Zone: Panics at the FFI Boundary

In the previous lessons, we've had to use unsafe blocks to handle pointers and cross the FFI boundary. This is because we are manually taking on responsibilities that the Rust compiler usually handles for us. One of the most significant dangers in this zone is a panic.

A panic in Rust is an unrecoverable error that stops the current thread of execution. By default, it unwinds the stack, running destructors to clean up resources. However, this unwinding process is specific to Rust. A C# or C runtime has no idea what to do with it. Allowing a Rust panic to unwind across the FFI boundary into foreign code is Undefined Behavior (UB), which in practice often means your Unity editor or game will instantly crash.

First time trying to do FFI bindings - Rust Users Forum

A great starting point to understand this problem is a discussion on the Rust Users Forum. The top answer makes a crucial point about FFI error handling.

Read the initial question and the first response. Pay close attention to the warning: 'Unwrapping everything... is one of the worst possible things you can do while in FFI'. This directly explains why we can't just use .unwrap() or .expect() in our FFI-exposed functions, as they can cause panics.

To see this in action, let's intentionally break our code. In your Rust library's process_command function, add a panic! macro right at the beginning.

In src/lib.rs:




#[no_mangle]
pub extern "C" fn process_command(command_json: *const c_char) -> *mut c_char {
    panic!("This is a deliberate panic from Rust!"); // Add this line

    // ... rest of the function ...
}

Now, recompile the Rust library (cargo build), copy the new .dll file into Unity, and run your scene. Your Unity editor will likely freeze and crash. This is the exact problem we need to solve. Remember to remove the panic! line after this experiment.


2. The Solution: Catching the Unwind

To prevent panics from crossing the FFI boundary, Rust provides a function called std::panic::catch_unwind. It works like a try...catch block for panics. You pass it a closure, and it returns a Result:

  • Ok(value) if the closure finished successfully, containing the return value of the closure.
  • Err(_) if the closure panicked.

We can wrap the core logic of our process_command function in catch_unwind. If a panic occurs, we can catch it and return a specific, structured error message instead of letting it crash the application.

First time trying to do FFI bindings - Rust Users Forum

The forum post we just looked at not only identifies the problem but also points to the solution. The second detailed response provides a clear code example.

Focus on the code example provided in this response. Notice how it wraps the core logic in std::panic::catch_unwind(). This is the exact pattern we will implement. The example uses a different error handling style (returning an integer code), but the catch_unwind principle is the same.

Performance Cost of Safety

Given your interest in game optimization, it's worth noting that this safety mechanism isn't free. There is a small but measurable performance overhead to using catch_unwind.

How we replace most of our HPC# code with Rust code - Unity Engine

For a practical look at this cost, let's refer back to the Unity forum discussion from our last lesson. The author provides excellent benchmarks.

Scroll down to the benchmark results table. Compare the performance of 'CallRust' vs. 'CallRustCatchUnwind'. You'll see that adding the panic-catching mechanism adds a few nanoseconds per call. In the vast majority of cases, this is a tiny price to pay for application stability, but it's an important trade-off to be aware of.


3. Implementing a Structured Error System

Currently, our CommandResponse enum returns an error as a simple String. We can make this far more powerful by creating a structured error enum that is serializable to JSON. This allows the C# side to programmatically understand what kind of error occurred and react accordingly.

Rust Error Handling with a Unified Enum
This diagram illustrates the pattern we'll implement. Various sources of failure (invalid input, panics, processing errors) are channeled into a single, structured `FfiError` enum, which our application can then handle in a unified way.

Let's refactor our Rust code to implement this.

Step 1: Define FfiError in Rust

In src/lib.rs, add a new enum for our errors. This will replace the simple String in our CommandResponse.

// In src/lib.rs, alongside your other definitions
use serde::{Serialize, Deserialize};
use std::panic;

// New structured error enum



#[derive(Serialize, Deserialize, Debug)]
pub enum FfiError {
    NullCommand,
    InvalidJson(String),
    ProcessingFailed(String),
    Panic(String),
}

// Update CommandResponse to use the new error type



#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "ResponseType")]
pub enum CommandResponse {
    Success { msg: String },
    Error { error: FfiError },
}

Step 2: Update process_command with catch_unwind

Now, we'll wrap our function's logic and map all failure points to our new FfiError enum.

// In src/lib.rs, replace your existing process_command function



#[no_mangle]
pub extern "C" fn process_command(command_json: *const c_char) -> *mut c_char {
    // 1. Wrap the entire operation in catch_unwind
    let result = panic::catch_unwind(|| {
        // 2. Handle null pointer input
        if command_json.is_null() {
            return CommandResponse::Error { error: FfiError::NullCommand };
        }
        
        // Safely convert the C string to a Rust string slice
        let command_str = unsafe { CStr::from_ptr(command_json) }.to_str()
            .map_err(|e| FfiError::InvalidJson(format!("Invalid UTF-8 in command: {}", e)))?;

        if command_str.is_empty() {
            // An empty string is not valid JSON
             return Ok(CommandResponse::Error { error: FfiError::InvalidJson("Received empty command string.".to_string()) });
        }

        // 3. Deserialize the JSON and process the command
        let command: GameCommand = serde_json::from_str(command_str)
            .map_err(|e| FfiError::InvalidJson(format!("Deserialization failed: {}", e)))?;

        let response = match command {
            GameCommand::LogMessage { msg } => {
                println!("[Rust Command] LogMessage: {}", msg);
                CommandResponse::Success { msg: format!("Log command processed: '{}'", msg) }
            }
            GameCommand::SetCharacterPosition { character_id, position } => {
                println!("[Rust Command] SetCharacterPosition for char {}: ({}, {}, {})", character_id, position.x, position.y, position.z);
                CommandResponse::Success { msg: format!("Character {} moved successfully.", character_id) }
            }
        };

        Ok(response)
    });

    // 4. Handle the result from catch_unwind
    let response = match result {
        // The closure completed successfully
        Ok(Ok(resp)) => resp,
        // The closure returned a structured error
        Ok(Err(ffi_err)) => CommandResponse::Error { error: ffi_err },
        // The closure panicked
        Err(e) => {
            let panic_msg = if let Some(s) = e.downcast_ref::<&'static str>() {
                s.to_string()
            } else if let Some(s) = e.downcast_ref::<String>() {
                s.clone()
            } else {
                "An unknown panic occurred.".to_string()
            };
            CommandResponse::Error { error: FfiError::Panic(panic_msg) }
        }
    };
    
    // 5. Serialize the final response and return it
    let response_str = serde_json::to_string(&response).unwrap();
    let c_str_response = CString::new(response_str).unwrap();
    c_str_response.into_raw()
}

Note: The Ok(Ok(resp)) and Ok(Err(ffi_err)) might look strange. This is because our closure itself returns a Result<CommandResponse, FfiError>, and catch_unwind wraps that in another Result.


4. Updating Unity to Understand Structured Errors

Now we need to teach Unity about our new FfiError structure.

Step 1: Update C# Data Structures

In your FFIStructs.cs file, create a representation of the FfiError and update CommandResponseData.

// In FFIStructs.cs

[System.Serializable]
public struct FfiErrorData
{
    // These fields correspond to the variants of the FfiError enum in Rust.
    // When Rust serializes the enum, it will include a field for the active variant.
    // e.g., for NullCommand, the JSON will be `{"NullCommand":null}`
    // e.g., for InvalidJson, `{"InvalidJson":"error message"}`
    // JsonUtility is not smart enough to handle this directly, but we can check for field existence.
    public string InvalidJson;
    public string ProcessingFailed;
    public string Panic;
    // We don't need a field for NullCommand as we can infer it if the others are null.
}

[System.Serializable]
public struct CommandResponseData
{
    public string ResponseType;

    // Success field
    public string msg;

    // Error field (now contains the structured error)
    public FfiErrorData error;
}

Step 2: Update the GameManager Logic

In GameManager.cs, let's improve our SendCommand method to inspect the structured error.

// In GameManager.cs

void SendCommand(string json)
{
    Debug.Log($"Sending JSON: {json}");
    IntPtr responsePtr = IntPtr.Zero;
    try
    {
        responsePtr = RustBridge.ProcessCommand(json);
        string responseJson = Marshal.PtrToStringUTF8(responsePtr);
        Debug.Log($"Received Response: {responseJson}");

        var responseData = JsonUtility.FromJson<CommandResponseData>(responseJson);
        
        if (responseData.ResponseType == "Success")
        {
            Debug.Log($"<color=green>Rust confirmed: {responseData.msg}</color>");
        }
        else // It's an Error
        {
            // Now we can check the *type* of error
            if (responseData.error.Panic != null)
            {
                Debug.LogAssertion($"<color=red>FATAL RUST PANIC: {responseData.error.Panic}</color>");
            }
            else if (responseData.error.InvalidJson != null)
            {
                Debug.LogError($"<color=orange>Rust FFI Error (Invalid JSON): {responseData.error.InvalidJson}</color>");
            }
            else if (responseData.error.ProcessingFailed != null)
            {
                Debug.LogError($"<color=orange>Rust FFI Error (Processing Failed): {responseData.error.ProcessingFailed}</color>");
            }
            else // Assume NullCommand if other fields are null
            {
                Debug.LogError($"<color=orange>Rust FFI Error: Received a null command pointer.</color>");
            }
        }
    }
    catch (Exception e)
    {
        Debug.LogError($"A C# exception occurred during FFI call: {e.Message}");
    }
    finally
    {
        if (responsePtr != IntPtr.Zero)
        {
            RustBridge.FreeString(responsePtr);
        }
    }
}

Note: Unity's built-in JsonUtility is simple and doesn't handle enums as elegantly as serde. A more advanced project might use a library like Newtonsoft.Json, but for now, checking for null fields works as a straightforward way to identify the error type.

Now, recompile Rust, copy the DLL, and run the scene in Unity. Try sending both valid and invalid JSON. You'll see the specific, color-coded error messages in the console. If you re-add the panic! line to the Rust code, you will now see the FATAL RUST PANIC assertion message instead of a crash!


Conclusion

Congratulations! You have now fortified the bridge between Unity and Rust. Our communication channel is no longer fragile; it's a robust pipeline that can handle invalid data and even internal panics in the Rust core without bringing down the entire game.

Key Takeaways:

  • A Rust panic unwinding across an FFI boundary is Undefined Behavior and will likely crash your application.
  • The std::panic::catch_unwind function is the standard mechanism for safely catching panics at the FFI boundary.
  • Using structured, serializable error enums (like our FfiError) provides far more context to the calling language than simple error strings.
  • This enhanced safety comes at a minor, but measurable, performance cost, which is a critical trade-off to understand in game development.
  • The fundamental pattern of allocate -> pass pointer -> use -> call free for memory management remains essential for all FFI communication.

This lesson concludes our work on the foundational architecture. We now have a stable, two-way communication system. In our next lesson, we will begin Module 3 and start building the core gameplay systems, beginning with: Design a JSON schema for character data including name, age, personality, and traits.