Error Handling

View Source

This guide covers error handling patterns in C3nif to write robust NIFs that don't crash the BEAM VM.

The Problem

Unlike Elixir, C3 has no exception handling mechanism. Unhandled errors can crash the entire Erlang VM, not just the calling process. C3nif provides patterns to safely handle errors and convert them to Elixir-friendly results.

C3's Optional Types

C3 uses optional types (suffixed with ?) for operations that can fail:

int? value = arg.get_int(&e);  // Returns int or fault

Handling Optionals

Use if (catch ...) to handle faults:

int? value = arg.get_int(&e);
if (catch err = value) {
    // Handle the error - `err` contains the fault
    return term::make_badarg(&e).raw();
}
// Use `value` safely here - it's now unwrapped

Propagating Faults

Use ! to propagate faults to the caller:

fn int? add_values(Env* e, Term a, Term b) {
    int val_a = a.get_int(e)!;  // Propagates fault if fails
    int val_b = b.get_int(e)!;
    return val_a + val_b;
}

The Two-Layer NIF Pattern

Every NIF should use this pattern for safety:

import c3nif::safety;

// INNER function - uses optionals, propagates faults
fn Term? double_impl(Env* e, ErlNifTerm* argv, CInt argc) {
    Term arg = safety::get_arg(argv, argc, 0)!;
    int value = safety::require_int(e, arg)!;
    return term::make_int(e, value * 2);
}

// OUTER function - fault barrier
<* nif: arity = 1 *>
fn ErlNifTerm double_value(
    ErlNifEnv* env_raw,
    CInt argc,
    ErlNifTerm* argv
) {
    Env e = env::wrap(env_raw);

    // Catch ALL faults at the boundary
    Term? result = double_impl(&e, argv, argc);
    if (catch fault = result) {
        return term::make_badarg(&e).raw();
    }

    return result.raw();
}

Why Two Layers?

  1. Inner function - Clean code using ! for fault propagation
  2. Outer function - Fault barrier that catches all faults before they reach the BEAM

Fault Types

C3nif defines several fault types:

term.c3 Faults

FaultMeaning
BADARGTerm is not the expected type
OVERFLOWValue doesn't fit in target type
ENCODINGString encoding error

safety.c3 Faults

FaultMeaning
ALLOC_FAILEDMemory allocation failure
RESOURCE_ERRORResource extraction failed
ENCODE_ERRORTerm encoding failed
ARGC_MISMATCHWrong number of arguments

Safety Helpers

The safety module provides validated extraction helpers:

import c3nif::safety;

// Validate argument count
safety::require_argc(2, argc)!;

// Safe argument access
Term arg0 = safety::get_arg(argv, argc, 0)!;
Term arg1 = safety::get_arg(argv, argc, 1)!;

// Type-validated extraction
int a = safety::require_int(&e, arg0)!;
int b = safety::require_int(&e, arg1)!;

// Range validation
int positive = safety::require_positive(&e, arg0)!;
int bounded = safety::require_int_range(&e, arg0, 0, 100)!;
int non_neg = safety::require_non_negative(&e, arg0)!;

// Type checking
safety::require_atom(&e, arg0)!;      // Fails if not atom
safety::require_list(&e, arg0)!;      // Fails if not list
safety::require_tuple(&e, arg0)!;     // Fails if not tuple
safety::require_map(&e, arg0)!;       // Fails if not map

// Extract typed values
ErlNifBinary bin = safety::require_binary(&e, arg0)!;
ErlNifPid pid = safety::require_pid(&e, arg0)!;

Return Value Patterns

Returning Success

// Simple value
return term::make_int(&e, 42).raw();

// Atom
return term::make_atom(&e, "ok").raw();

// Tuple {:ok, value}
return term::make_ok_tuple(&e, term::make_int(&e, result)).raw();

Returning Errors

// Raise ArgumentError
return term::make_badarg(&e).raw();

// Return {:error, reason}
return term::make_error_tuple(&e, term::make_atom(&e, "not_found")).raw();

// Shorthand for atom reason
return term::make_error_atom(&e, "invalid_input").raw();

The NifResult Type

For more explicit error handling, use NifResult:

fn NifResult process_data(Env* e, Term arg) {
    int? value = arg.get_int(e);
    if (catch err = value) {
        return safety::badarg(e);
    }

    if (value < 0) {
        return safety::error(e, "negative_not_allowed");
    }

    return safety::ok(term::make_int(e, value * 2));
}

// In the NIF:
NifResult result = process_data(&e, arg);
if (result.is_error) {
    return result.value.raw();
}
return result.value.raw();

Error Conversion Helpers

Convert faults to error tuples:

Term? result = risky_operation(&e);
if (catch fault = result) {
    // Convert fault to appropriate error
    return safety::make_badarg_error(&e).raw();
    // Or: safety::make_overflow_error(&e).raw()
    // Or: safety::make_alloc_error(&e).raw()
    // Or: safety::make_resource_error(&e).raw()
    // Or: safety::make_unknown_error(&e).raw()
}

What You Cannot Catch

Some errors will still crash the BEAM:

  • Segmentation faults - Invalid pointer access
  • Stack overflow - Deep recursion or large stack allocations
  • Unsafe C3 code - Using !! force unwrap, raw pointer arithmetic
  • Integer overflow - Without trap-on-wrap enabled

Prevention Strategies

  1. Never use !! - Always handle optionals explicitly
  2. Validate all inputs - Check types and ranges early
  3. Use bounds checking - Access arrays safely
  4. Test with sanitizers - Use AddressSanitizer in CI

Complete Example

import c3nif;
import c3nif::erl_nif;
import c3nif::env;
import c3nif::term;
import c3nif::safety;

// Inner implementation - clean, uses fault propagation
fn Term? divide_impl(Env* e, ErlNifTerm* argv, CInt argc) {
    // Validate argument count
    safety::require_argc(2, argc)!;

    // Extract arguments safely
    Term arg0 = safety::get_arg(argv, argc, 0)!;
    Term arg1 = safety::get_arg(argv, argc, 1)!;

    int numerator = safety::require_int(e, arg0)!;
    int denominator = safety::require_int(e, arg1)!;

    // Business logic validation
    if (denominator == 0) {
        return term::BADARG?;  // Return fault for division by zero
    }

    return term::make_int(e, numerator / denominator);
}

// Outer NIF - fault barrier
<* nif: arity = 2 *>
fn ErlNifTerm divide(
    ErlNifEnv* raw_env,
    CInt argc,
    ErlNifTerm* argv
) {
    Env e = env::wrap(raw_env);

    Term? result = divide_impl(&e, argv, argc);
    if (catch fault = result) {
        // All faults become badarg - or use more specific handling
        return term::make_badarg(&e).raw();
    }

    return result.raw();
}

Best Practices

  1. Always use the two-layer pattern - Inner implementation, outer fault barrier

  2. Validate early - Check argument count and types at the start

  3. Use safety helpers - They provide consistent error handling

  4. Return meaningful errors - Use {:error, reason} tuples when appropriate

  5. Document error conditions - Tell users what can fail and why

  6. Test error paths - Ensure invalid inputs return errors, not crashes