How to Fix: Cranelift: Calling Rust function causes failed assertion when calling finalize_definitions on M1 MacOS

5 min read

If Cranelift JIT crashes on Apple Silicon when you register and call a Rust function, the failure is usually not in your Rust code at all. The assertion that trips during finalize_definitions is most often caused by a calling convention mismatch or an incorrectly declared extern "C" function signature on M1 macOS, where ABI rules are less forgiving than on some x86_64 setups.

Understanding the Root Cause

This issue appears when JIT-generated code calls back into a host Rust function such as my_function, and the function is exposed to Cranelift with a signature that does not exactly match what the platform expects.

On AArch64 Apple Silicon, Cranelift must emit a call that follows the system ABI precisely. If the JIT declares the imported symbol with the wrong parameter types, wrong return type, wrong calling convention, or inconsistent linkage assumptions, relocation and code finalization can fail, often surfacing as an assertion during finalize_definitions().

The most common causes are:

  • The Rust function is not declared as extern "C".
  • The Cranelift signature does not match the native function exactly.
  • The symbol is registered with an address, but the imported function declaration in Cranelift uses incompatible types.
  • The JIT is using a default calling convention that differs from the host platform ABI expectations.
  • The function pointer cast is incorrect when passed to jit_builder.symbol(...).

In practice, Apple Silicon highlights these ABI mistakes quickly because argument passing, register assignment, and symbol call lowering are architecture-specific. A mismatch that might appear to "work" elsewhere can fail reliably on M1.

Step-by-Step Solution

The fix is to ensure that the host function, the registered symbol, and the Cranelift function signature all agree exactly.

1. Declare the Rust function with the correct ABI

extern "C" fn my_function(val: i32) {
    println!("The value is: {val}");
}

The extern "C" is essential. Without it, Rust uses its own ABI, which is not what Cranelift expects for a native host call.

2. Register the symbol with the correct function pointer

use cranelift_jit::JITBuilder;

let mut jit_builder = JITBuilder::new(cranelift_module::default_libcall_names());
jit_builder.symbol("my_function", my_function as *const u8);

Use the function pointer directly and cast it consistently. Avoid unnecessary transmute operations unless absolutely needed.

3. Import the function into the Cranelift module with a matching signature

use cranelift::prelude::*;
use cranelift_jit::JITModule;
use cranelift_module::{Linkage, Module};

let mut module = JITModule::new(jit_builder);
let mut sig = module.make_signature();
sig.params.push(AbiParam::new(types::I32));

let callee = module
    .declare_function("my_function", Linkage::Import, &sig)
    .unwrap();

If your host function returns a value, add the corresponding return type in both places. For example, an i32 return in Rust must also be declared with:

sig.returns.push(AbiParam::new(types::I32));

4. Use the imported function correctly in Cranelift IR

let local_callee = module.declare_func_in_func(callee, &mut builder.func);
let arg = builder.ins().iconst(types::I32, 42);
builder.ins().call(local_callee, &[arg]);

The generated call must pass the exact number and type of arguments expected by the imported symbol.

5. Finalize the module only after all declarations are consistent

module.define_function(func_id, &mut ctx).unwrap();
module.clear_context(&mut ctx);
module.finalize_definitions().unwrap();

If finalize_definitions() still fails, inspect every imported function signature and verify there is no hidden mismatch.

6. Full minimal pattern

use cranelift::prelude::*;
use cranelift_frontend::{FunctionBuilder, FunctionBuilderContext};
use cranelift_jit::{JITBuilder, JITModule};
use cranelift_module::{default_libcall_names, Linkage, Module};

extern "C" fn my_function(val: i32) {
    println!("The value is: {val}");
}

fn build_module() {
    let mut jit_builder = JITBuilder::new(default_libcall_names());
    jit_builder.symbol("my_function", my_function as *const u8);

    let mut module = JITModule::new(jit_builder);
    let mut ctx = module.make_context();
    let mut func_ctx = FunctionBuilderContext::new();

    let mut sig = module.make_signature();
    sig.params.push(AbiParam::new(types::I32));
    let imported = module.declare_function("my_function", Linkage::Import, &sig).unwrap();

    ctx.func.signature = module.make_signature();

    let mut builder = FunctionBuilder::new(&mut ctx.func, &mut func_ctx);
    let block = builder.create_block();
    builder.switch_to_block(block);
    builder.seal_block(block);

    let local = module.declare_func_in_func(imported, &mut builder.func);
    let arg = builder.ins().iconst(types::I32, 7);
    builder.ins().call(local, &[arg]);
    builder.ins().return_(&[]);
    builder.finalize();

    let func_id = module.declare_function("run", Linkage::Export, &ctx.func.signature).unwrap();
    module.define_function(func_id, &mut ctx).unwrap();
    module.clear_context(&mut ctx);
    module.finalize_definitions().unwrap();
}

This pattern works because it keeps the native symbol registration, Cranelift import signature, and generated call site aligned.

Common Edge Cases

  • Missing return type: If the Rust function returns a value but the Cranelift signature does not, code generation may become invalid.
  • Wrong integer width: Using I64 in Cranelift for a Rust i32 parameter is enough to break the call ABI.
  • Boolean or struct arguments: Composite types and Rust-specific layouts can be tricky across FFI boundaries. Prefer primitive C-compatible types first.
  • Rust ABI instead of C ABI: A plain fn my_function(...) is not equivalent to extern "C" fn.
  • Incorrect symbol name: If the declared import name and the registered symbol name differ, Cranelift may resolve the wrong target or fail later in the pipeline.
  • Architecture-specific assumptions: Code tested on x86_64 may accidentally rely on behavior that fails on aarch64-apple-darwin.
  • Unsafe pointer casts: Casting the function address inconsistently can hide the real problem and make debugging harder.

FAQ

Why does this fail at finalize_definitions() instead of at the call site?

Because Cranelift completes relocation, machine code emission, and final linkage work during finalization. ABI or relocation inconsistencies often become visible only at that stage.

Do I always need extern "C" for functions called from Cranelift JIT?

Yes, if you are exposing a host function as a native symbol for JIT-generated code to call, you should use a stable C ABI unless you have a very specific platform-controlled alternative.

Why is this more visible on M1 Mac?

Apple Silicon uses the AArch64 ABI, and ABI mismatches around parameters, returns, and register usage are often exposed more reliably there than in loosely tested desktop JIT examples.

The practical fix is simple: define the callback as extern "C", register the exact symbol address, and make the Cranelift import signature match the Rust function byte-for-byte in ABI terms. Once those three pieces line up, the assertion during finalize_definitions() typically disappears.

Leave a Reply

Your email address will not be published. Required fields are marked *