Mehdi Akiki
Rust Failure Atlas / FFI and targets

RFA-706 · Case file with fixtures · Case 678 of 694 · Compiler evidence

c_void Is Not Rust's Return Type for C void

Rust's c_void represents the opaque object behind a C void pointer. A C function which returns void should return Rust's unit type (), a distinction Rust 1.98 now diagnoses directly.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all FFI targets
Profiles
dev, release

Direct answer

What this Rust failure means

Why it happens
Rust's c_void is an opaque FFI data type rather than Rust's spelling for an absent return value, while C void return semantics map to the unit type ().
First discriminating check
Inspect every extern declaration returning c_void and distinguish a C void return from a pointer to void before replacing only the return type with ().

I used to find this spelling convincing:

use std::ffi::c_void;

pub unsafe extern "C" fn notify_host() -> c_void {
    loop {}
}

The function is C-compatible, c_void has “C” and “void” in its name, and the function does not produce a useful value. It looks reasonable. Rust 1.98 explains why it is not.

The new c_void_returns lint says that declarations returning c_void are not compatible with C functions returning void. A C function returning void maps to Rust's unit return type, (). The type c_void has a different job: it represents the unknown object type used behind a C void * pointer.

That one distinction fixes the problem:

pub unsafe extern "C" fn notify_host() {
    // The omitted Rust return type is ().
}

The failing fixture enables the warning as an error, and the repaired fixture uses the unit return. Both are checked with Rust 1.98.1.

Two meanings hidden behind one C keyword

C uses void in two related-looking but technically different places.

In a function declaration, void means the call does not return a value:

void notify_host(void);

The matching Rust declaration returns ():

unsafe extern "C" {
    fn notify_host();
}

In a pointer type, void * means a pointer whose pointee type is not described at this boundary:

void *context;

That is where Rust's c_void belongs:

use std::ffi::c_void;

struct CallbackState {
    context: *mut c_void,
}

The pointer still has all the usual pointer obligations. It may be null, aligned or misaligned for the value I later cast it to, live or dangling, uniquely owned or shared. c_void does not solve any of these questions. It only lets the FFI signature say, “the concrete pointee type is outside this declaration.”

Why c_void cannot be a returned value

Rust defines c_void so it can serve as the pointee of raw pointers. It is not a regular value I can construct and return. Making the function diverge with loop {} may satisfy Rust's ordinary expression typing, but it does not make the ABI declaration correct.

This is an important FFI principle: a Rust body proving that it never reaches a return statement is different from the foreign caller and callee agreeing about the function type. ABI declarations describe the machine-level contract at every call site. Control flow inside one implementation cannot rewrite that contract.

The wrong signature may remain unnoticed when the function is only declared, generated by a binding tool, hidden behind a rarely built target, or accepted with a warning. I prefer to deny this lint in FFI-heavy crates because the repair is small and the boundary is important.

Do not replace every c_void with ()

The compiler's advice is about a return position representing C void. It is not a general migration away from c_void.

These are different contracts:

use std::ffi::c_void;

unsafe extern "C" {
    fn release_context(context: *mut c_void); // C: void release_context(void *)
    fn make_context() -> *mut c_void;          // C: void *make_context(void)
    fn flush_context(context: *mut c_void);    // C: void flush_context(void *)
}

The first and third functions return unit. All three may correctly use *mut c_void for an opaque context pointer. Replacing those pointer types with () would destroy useful information and produce a different ABI.

When I review generated bindings, I compare one complete C declaration with one complete Rust declaration. Searching and replacing the token void is not enough because the surrounding * changes its meaning.

Unit is not a one-byte placeholder

Another wrong repair is to choose u8 because it feels like a small empty value. That invents a return register where the C declaration promises no result. The foreign caller may treat whatever bits happen to be there as a value, or the platforms may disagree about how the return travels.

Rust unit is the language representation of “no returned value” for this FFI case. It is a zero-sized Rust type, but I do not reason from its size alone. The compiler has explicit ABI knowledge for an extern "C" unit return.

Likewise, ! means the Rust function never returns at all. It is not an automatic substitute for a C void function which normally returns control to its caller. If the native API really has a non-returning contract, that deserves its own documented boundary and target review.

A review method I can repeat

For each foreign function I write the native declaration beside the Rust declaration. Then I classify every appearance of C void:

  1. a return with no value maps to ();
  2. a void * maps to *mut c_void or *const c_void;
  3. a parameter list written as (void) means no parameters, not one c_void parameter;
  4. a function which never returns is a separate semantic promise.

I also check calling convention, integer widths, constness, nullability, ownership, and unwinding. Fixing the return type does not certify those other parts.

The smallest regression is useful because it guards the exact mistake. I compile the declaration with #![deny(c_void_returns)], keep the pointer cases which are intentional, and repair only the value-return case. This turns a name-based guess into an executable ABI check.

The core principle goes beyond Rust: an FFI type name is meaningful only in its full syntactic position. C's void return and C's void * share a word, but they do not share a machine contract.