[Discussion] Safety marks for non thread-safe ffi interfaces

Currently I'm working on a crate writting FFI dlls, and found the following questions:

  • some FFI (called write for brief) interface is not thread-safe,
  • other FFI (called read for brief) interface could be called concurrencely, but write cannot be called while any read is not finished.
  • the FFI calls Rust with single thread only, no need to consider whether 2 write could be called concurrencely.
#[no_mangle]
extern fn exported(data:i32)->i32 {
    {
        let readed=read(data); // fine
        let readed_2=read(data+1); // multiple data is readed, fine
        // write(readed); // should not do that, since the `readed` item could be modified while write is executing.
    }
    {
        write(..Default::default()); // fine
        write(..Default::default()); // fine
        let readed = read(data); // fine
        // write(readed); // cannot write here since readed is not dropped.
    }
    0
}
#[no_mangle]
extern fn another_exported(data:i32, verbose:bool)->i32{
    if (verbose) { println!("data is {data}") }
    // let readed = read(data); // cannot be executed here, since `exported` uses write, which could directly modify the `readed` variable.
    let result = exported(data);
    let readed = read(data); // fine, since no more `write` is called.
    result
}

It might be a good idea introducing a new pseudo variable, borrow_checker.

struct BorrowChecker;
impl BorrowChecker {
    pub fn read<'a>(&'a self, data:i32)->Readed<'a> {...}
    pub fn write(&mut self, data:*const c_void) {...}
}
#[no_mangle]
extern fn exported(borrow_checker:&mut BorrowChecker, data:i32)->i32 {
    {
        let readed=borrow_checker.read(data); // fine
        let readed_2=borrow_checker.read(data+1); // multiple data is readed, fine
        // unsafe { borrow_checker.write(&readed as *const c_void) } // cannot borrow `borrow_checker` as mutable.
    }
    {
        write(ptr::null()); // fine
        write(ptr::null()); // fine
        let readed = borrow_checker.read(data); // fine
        // borrow_checker.write(&readed as *const c_void); // cannot borrow `borrow_checker` as mutable.
    }
    0
}
#[no_mangle]
extern fn another_exported(borrow_checker:&mut BorrowChecker, data:i32, verbose:bool)->i32{
    if (verbose) { println!("data is {data}") }
    // let readed = borrow_checker.read(data); // cannot be executed here, since `exported` should borrow borrow_checker exclusively later.
    let result = exported(borrow_checker, data);
    let readed = borrow_checker.read(data); // fine, since no more `write` is called.
    result
}

The modification above suits all the restrictions, except one thing: The FFI knows nothing about &mut BorrowChecker, and they could not send borrow_checker directly to Rust, thus the exported functions are not callable outside Rust.

Here comes my request: adding a #[borrow_checker] flag to some ZST struct

#[borrow_checker]
pub struct BorrowChecker;

The attribute borrow_checker means that:

  • Construct a BorrowChecker needs an unsafe block
  • if T is marked as borrow_checker, all of T &mut T and &T in function parameters do not modify the ffi signatures, calling such function with omit T, &mut T or &T requires an unsafe block, which means that:
extern fn foo(bc:&mut BorrowChecker){}
// could be called with:
fn main(){
    // 1: FFI-compatitable call
    unsafe {foo()} // call foo with omitted parameters requires an unsafe block
    // 2: Rusty call
    let mut bc = unsafe {BorrowChecker}; // construct BorrowChecker is unsafe.
    foo(&mut bc); // call foo with `&mut BorrowChecker` is safe.
}

Since the foo could be called without &mut BorrowChecker, it could be called in the FFI side, and since foo has a &mut BorrowChecker parameter, it could use Rust's borrow checker to check whether the thread-safety rule is violated.

Usage:

#[borrow_checker]
pub struct BorrowChecker;
extern "C" {
    // since the borrow of BorrowChecker could be omitted, the ffi interface does not contains `&BorrowChecker` or `&mut BorrowChecker`
    fn read(_:&BorrowChecker, data:i32); // It is actually `fn read(data:i32)`
    fn write(_:&mut BorrowChecker, data:*const c_void);// It is actually `fn write(data:*const c_void)`
}
#[no_mangle]
// FFI side should call `foo(data)` directly, the `bc` is a marker and thus need not be sent.
extern fn foo(bc:&mut BorrowChecker, data:i32)->i32{...}

Any suggestions?

This very much feels like a thing that could (and maybe should) be done by an external library, but it doesn't make sense to include in the standard distribution. Plus, turning &mut T into a ZST for some pointees, while certainly a very interesting prospect, is quite a different beast from current wide pointer support.

It might be interesting for your design to note that ZST are invisible to the ABI. I don't recall how much that is guaranteed (I don't think it is), and it loses you the automatic reborrowing support of references, but it can be snuck into FFI signatures.

More practical for a custom attribute (bare #[no_mangle] is essentially unsound, even though we do heavily attempt to mitigate that, so you always want a wrapper anyway) is just to generate the shim between your desired FFI signature and the more convenient Rust signature. There's no extra cost to the added function call layer because it'll trivially optimize out.

3 Likes

Adding a wrapper could be done gracefully with proc-macros (I'll modify it later in the main thread). I cannot believe that the &mut BorrowChecker could be optimize out directly.

Thank you for your reply:)

// such code could be done with macros
#[no_mangle]
unsafe extern fn _ffi(signatures:...)->...{ffi(&mut unsafe{BorrowChecker::new()}, signatures)}
fn ffi(bc:&mut BorrowCheckers, signatures:...)->...{...}