Checking for memory leaks in FFI

I'm trying to understand how to check for memory leaks in C code that calls Rust and am not sure if the approach I am taking is correct or, if it is not even possible to do what I am after.

As an example, from a previous question on this board (Passing vector of vectors buffer to C), I have the following implementation:

lib.rs

use ::core::{convert::TryInto, slice};
use ::libc::size_t;
use ::scopeguard::defer_on_unwind;

#[repr(C)]
pub struct DynArray {
    array: *mut i32,
    array_len: size_t,
    component_sizes: *mut size_t,
    component_sizes_len: size_t,
}

#[no_mangle]
pub extern "C" fn rust_alloc() -> DynArray {
    defer_on_unwind!({
        ::std::process::abort();
    });
    let v: Vec<Vec<i32>> = vec![vec![1, 2, 3], vec![1, 2, 3]];
    let l: Vec<size_t> = v
        .iter()
        .map(|v| v.len().try_into().expect("Integer Overflow"))
        .collect();
    let l: Box<[size_t]> = l.into_boxed_slice();

    let v: Box<[i32]> = v.concat().into_boxed_slice();

    DynArray {
        array_len: v.len().try_into().expect("Integer Overflow"),
        array: Box::into_raw(v) as _,
        component_sizes_len: l.len().try_into().expect("Integer Overflow"),
        component_sizes: Box::into_raw(l) as _,
    }
}

#[no_mangle]
pub unsafe extern "C" fn rust_free(array: DynArray) {
    defer_on_unwind!({
        ::std::process::abort();
    });
    let DynArray {
        array,
        array_len,
        component_sizes,
        component_sizes_len,
    } = array;
    drop::<Box<[i32]>>(Box::from_raw(slice::from_raw_parts_mut(
        array,
        array_len.try_into().expect("Integer Overflow"),
    )));
    drop::<Box<[usize]>>(Box::from_raw(slice::from_raw_parts_mut(
        component_sizes,
        component_sizes_len.try_into().expect("Integer Overflow"),
    )));
}

Cargo.toml

[package]
name = "example"
version = "0.1.0"
authors = ["example <example@example.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
libc = "0.2.67"
scopeguard = "1.1.0"

[lib]
crate-type = ["cdylib"]

main.c

#include <stdint.h>
#include <stdio.h>

typedef struct {
    int32_t * array;
    size_t array_length;
    size_t * component_sizes;
    size_t component_sizes_length;
} DynArray_t;

DynArray_t rust_alloc (void);
void rust_free (DynArray_t);

int main ()
{
  DynArray_t vec = rust_alloc();
  
// Nothing shows in valgrind when this line is commented out
  rust_free(vec);
}

What I'd like to check is that the last call to rust_free(vec) is working as expected. I thought that by running Valgrind with this final line commented out I'd see some memory leaks highlighted. However Valgrind shows the same output irrespective of the inclusion of the final line. I compiled the code and ran valgrind with the following commands:

cargo build --release
gcc -o main main.c -L target/release -lexample
valgrind --leak-check=full env LD_LIBRARY_PATH=target/release/ ./main

with the output being similar to below (both with and without the final line in the c file)

==12574== Memcheck, a memory error detector
==12574== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==12574== Using Valgrind-3.14.0 and LibVEX; rerun with -h for copyright info
==12574== Command: env LD_LIBRARY_PATH=target/release/ ./main
==12574== 

Am I misunderstanding how to use Valgrind in this scenario? If so, is there anyway I can check that memory is being freed as expected when interfacing between C and Rust?

I hope this makes sense.

Apologies if you're set on using valgrind, but I've had a lot of success using address sanitizer for this purpose.

With rust_free():

$ gcc -fsanitize=address -o main main.c -L target/release -ltestleak
$ LD_PRELOAD=/usr/lib/arm-linux-gnueabihf/libasan.so.5 LD_LIBRARY_PATH=target/release/ ./main
(no output)

Without rust_free():

$ gcc -fsanitize=address -o main main.c -L target/release -ltestleak
$ LD_PRELOAD=/usr/lib/arm-linux-gnueabihf/libasan.so.5 LD_LIBRARY_PATH=target/release/ ./main

=================================================================
==10231==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 24 byte(s) in 1 object(s) allocated from:
    #0 0xb6a0ebbb in __interceptor_malloc (/usr/lib/arm-linux-gnueabihf/libasan.so.5+0xe1bbb)
    #1 0xb68e9f57 in alloc::slice::_$LT$impl$u20$$u5b$T$u5d$$GT$::concat::he5078b2c83652466 (target/release/libtestleak.so+0x1f57)
    #2 0xb6784717 in __libc_start_main /build/glibc-FUvrFr/glibc-2.28/csu/libc-start.c:308

Direct leak of 8 byte(s) in 1 object(s) allocated from:
    #0 0xb6a0ebbb in __interceptor_malloc (/usr/lib/arm-linux-gnueabihf/libasan.so.5+0xe1bbb)
    #1 0xb68e9d13 in alloc::raw_vec::RawVec$LT$T$C$A$GT$::reserve::hba06dd4d3e226abd (target/release/libtestleak.so+0x1d13)
    #2 0xb6784717 in __libc_start_main /build/glibc-FUvrFr/glibc-2.28/csu/libc-start.c:308

SUMMARY: AddressSanitizer: 32 byte(s) leaked in 2 allocation(s).
2 Likes

Not tied to Valgrind so that works perfectly. Thankyou!

I think it didn't work because the program you were auditing with valgrind was env.

You can remove the need for a LD_LIBRARY_PATH override if you use -Wl,-rpath='$ORIGIN'/ so that the binary will always look for libexample.so within its directory, so with a ln -s ./target/example/libexample.so beforehand it should work.

To skip the ln, for quick prototyping, if you are not moving the ./main binary around, you can do:

  • -Wl,-rpath='$ORIGIN/target/release' (relative path to ./main, wherever it is when run; note the simple quotes);

  • and also: -Wl,-rpath=./target/release (relative (or absolute using "$PWD/" instead of ./, note the double quotes) path to the compilation directory).

So now you've got two ways of solving your problem :slightly_smiling_face:; it can be interesting to compare libasan's output to valgrind's.

1 Like

:grinning: That makes sense. I'll mark that as the solution as it explains why the initial valgrind call failed. I've now also found a third solution. You can pass --trace-children=yes to the initial call to ensure valgrind traces into the env call:

valgrind --trace-children=yes --leak-check=full env LD_LIBRARY_PATH=target/release/ ./main

Thank you both.

1 Like