Skip to contents

Some C APIs do not just call functions immediately. They store a function pointer and call it later. ccallback() creates a C function pointer that enters R when the foreign code invokes it.

A minimal callback

This callback has the C shape:

int add(int x, int y);
add <- ccallback("ii)i", function(x, y) x + y)
dyncall(add, "ii)i", 20L, 3L)
#> [1] 23

The callback signature follows the same call-signature format used by dyncall().

Recursive callback call

A callback pointer is just another foreign function pointer. It can be passed back into a call if the signature expects a pointer.

factorial_callback <- function(x, fun) {
    if (x < 2L) {
        1L
    } else {
        x * dyncall(fun, "ip)i", x - 1L, fun)
    }
}

factorial_ptr <- ccallback("ip)i", factorial_callback)
dyncall(factorial_ptr, "ip)i", 6L, factorial_ptr)
#> [1] 720

This example is small, but it shows the same lifetime rule as larger APIs: keep factorial_ptr reachable in R for as long as C may call it.

Sorting with C qsort

qsort() is a classic C API that accepts a comparison callback:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

The comparison callback receives pointers to two elements. The R callback reads the double values with unpack().

libc_names <- c("msvcrt", "c", "c.so.6")
libc <- new.env(parent = globalenv())
dynbind(libc_names, "qsort(pLLp)v;", envir = libc)
#> dynbind report
#>   library: libc.so.6
#>   unresolved symbols: 0

compare_double <- function(px, py) {
    x <- unpack(px, 0L, "d")
    y <- unpack(py, 0L, "d")
    if (x < y) {
        -1L
    } else if (x > y) {
        1L
    } else {
        0L
    }
}

set.seed(42)
x <- rnorm(12L)
expected <- sort(x)
compare_ptr <- ccallback("pp)i", compare_double)

libc$qsort(x, length(x), 8L, compare_ptr)
#> NULL
all.equal(x, expected)
#> [1] TRUE

The callback object must remain assigned to compare_ptr until qsort() returns. For APIs that store callbacks beyond the registration call, store the callback object somewhere with the same lifetime as the foreign registration.

Aggregate values in callbacks

Callbacks can receive and return registered aggregate types by value with the same <Type> syntax used by dyncall().

cstruct("Point{dd}x y;")

point <- cdata(Point)
point$x <- 1.5
point$y <- 2.25

point_sum <- ccallback("<Point>)d", function(p) {
    p$x + p$y
})

dyncall(point_sum, "<Point>)d", point)
#> [1] 3.75

Aggregate arguments arrive as raw-backed struct objects, so field access uses the same $ helpers as ordinary cdata() objects. Aggregate return callbacks must return a raw-backed object of the exact registered type.

make_point <- ccallback("dd)<Point>", function(x, y) {
    out <- cdata(Point)
    out$x <- x
    out$y <- y
    out
})

returned <- dyncall(make_point, "dd)<Point>", 4.5, 5.25)
c(returned$x, returned$y)
#> [1] 4.50 5.25

Immediate and stored callbacks

Callback lifetime depends on whether C calls the pointer during the current function call or stores it for later.

C API pattern Example R-side lifetime rule
Immediate callback qsort() keep the callback object alive until the call returns
Stored callback GUI, audio, event, or async APIs keep the callback object alive until the foreign registration is removed

qsort() is comparatively safe because it finishes before control returns to R. Event loops are riskier because C may call the pointer long after the registration function has returned.

Error and lifetime rules

Callbacks cross from C into R, so mistakes are more severe than ordinary R function errors.

  • The callback signature must match the exact C callback type.
  • Keep the callback object alive in R while foreign code may call it.
  • Do not let C call a callback after its R state has been cleaned up.
  • Avoid throwing errors through foreign event loops. Handle errors inside the callback and return a C-level failure value when the API supports one.
  • For aggregate callback returns, return a raw-backed struct object of the exact expected type and size. A mismatch disables the callback.
  • Aggregate callbacks require an implemented 64-bit x86 or ARM64 dyncallback backend; unsupported platforms fail when the callback is created.

When a callback does throw an R error, rdyncall disables that callback pointer for later invocations. The status helpers make that state inspectable:

checked_callback <- ccallback("i)i", function(x) {
    if (x < 0L) {
        stop("negative values are not supported")
    }
    x
})

dyncall(checked_callback, "i)i", -1L)
callback_is_active(checked_callback)
callback_last_error(checked_callback)
callback_status(checked_callback)

Create a new callback when you need to recover after an error. The status helpers are intentionally read-only and do not re-enable disabled callbacks.

A safe registration pattern

For APIs that register callbacks, keep a small R object that owns both the foreign handle and the callback pointer.

make_registration <- function(handle) {
    state <- new.env(parent = emptyenv())
    state$handle <- handle
    state$callback <- ccallback("ip)v", function(code, userdata) {
        # Handle the event and return a C-compatible value.
        invisible(NULL)
    })

    foreign_register_callback(handle, state$callback)
    state
}

The important part is not the exact wrapper shape; it is that the callback pointer remains reachable for at least as long as the C library can use it.