API design: Need help working around closure type inference issues

I'm working on function hooking library (mainly for native game modding) that builds on my closure-ffi crate to allow capturing closures to be used as hooks. At the moment I'm just trying out different user-facing APIs for constructing the hook, to see what plays nicely with type inference.

One thing that is very useful to be able to do when writing hooks is to access a function pointer to that calls the original function (usually via a JIT'd trampoline) from within the hook body itself. I attempted to do this by having the user provide a "getter" FnOnce impl that is passed the hook context, and returns their hook closure. This way they can move it into their hook body.

I particularly like this solution because it doesn't require further macro-implementing traits for functions of different argument counts in the hooking crate (beyond what is already done in closure-ffi). However, this plays very poorly with type inference as can be showed in the example below (cut down to only the necessary parts to show the problem).

Code
use std::marker::PhantomData;

/* rough layout of the closure-ffi API */

// Calling convention marker types; only C provided for brevity
mod cc {
    #[derive(Default)]
    pub struct C;
}

/// Used to support an API that doesn't require passing the calling convention marker type
/// when the function type is known
pub unsafe trait FnPtr: Copy {
    type CC: Default;
}

unsafe impl<R> FnPtr for unsafe extern "C" fn() -> R {
    type CC = cc::C;
}

unsafe impl<T, R> FnPtr for unsafe extern "C" fn(T) -> R {
    type CC = cc::C;
}

// Has an associated const with a compiler-generated thunk function that gets a pointer to the closure
// and calls it.
//
// CC is included as a generic parameter instead of just B to allow downstream crates to implement
// FnThunk for higher kinded bare function types through a macro as needed
// For the same reason, we don't bound B with `FnPtr`
unsafe trait FnThunk<CC, B: Copy> {

}

// We implement for (CC, F) instead of just F to allow downstream crates to implement
// FnThunk for higher kinded bare function types through a macro as needed
unsafe impl<R, F> FnThunk<cc::C, unsafe extern "C" fn() -> R> for (cc::C, F) where F: Fn() -> R {}
unsafe impl<T, R, F> FnThunk<cc::C, unsafe extern "C" fn(T) -> R> for (cc::C, F) where F: Fn(T) -> R {}
// Higher parameter counts omitted for brevity

/* hooking library */

struct HookCtx<B: Copy> {
    original: B
}

struct Hook<'a, B: Copy>(PhantomData<(B, &'a ())>);

impl<'a, B: Copy> Hook<'a, B> {
    fn new<F: 'a + Sync>(hook: F) -> Self 
    where
        B: FnPtr,
        (<B as FnPtr>::CC, F): FnThunk<<B as FnPtr>::CC, B>
    {
        todo!()
    }

    fn with_cc<CC, F: 'a + Sync>(cc: CC, hook: F) -> Self
        where (CC, F): FnThunk<CC, B>
    {
        todo!();
    }

    fn with_context<CC, F: 'a + Sync, P>(cc: CC, hook: P) -> Self
    where
        P: FnOnce(HookCtx<B>) -> F,
        (CC, F): FnThunk<CC, B> 
    {
        todo!();
    }
} 

fn test_inference() {
    // Case 1: properly infers F
    let hook: Hook<'_, unsafe extern "C" fn(usize) -> usize>;
    hook = Hook::new(|arg| arg + 42);

    // Case 2: properly infers B
    let hook = Hook::with_cc(cc::C, |arg: u32| arg + 42);

    // Case 3: also properly infers B
    let hook = Hook::with_context(cc::C, |ctx| move || false);

    // Case 4: also properly infers B
    let hook = Hook::with_context(cc::C, |ctx| move |arg: u32| {
        println!("trampoline to original: {:016x}", ctx.original as usize);
        arg + 42
    });

    // (FAILS) Case 5: fails to infer B
    let hook = Hook::with_context(cc::C, |ctx| move |arg: u32| -> u32 {
        let _ = unsafe { (ctx.original)(arg) };
        arg + 42
    });

    // (FAILS) Case 6: also fails to infer B, even when it's in the type of the assigned variable
    let hook: Hook<'_, unsafe extern "C" fn(u32) -> u32>;
    let hook = Hook::with_context(cc::C, |ctx| move |arg: u32| -> u32 {
        let _ = unsafe { (ctx.original)(arg) };
        arg + 42
    });

    // Case 7: Works when the turbofish operator is used
    let hook = Hook::<unsafe extern "C" fn(u32) -> u32>::with_context(cc::C, |ctx| move |arg| {
        let _ = unsafe { (ctx.original)(arg) };
        arg + 42
    });

    // Case 8: Works when the outer closure arg is typed
    let hook = Hook::with_context(cc::C, |ctx: HookCtx<unsafe extern "C" fn(u32) -> u32>| move |arg| {
        let _ = unsafe { (ctx.original)(arg) };
        arg + 42
    });
}

Playground

What's particularly surprising to me is that case 4 in the above code type infers successfully, but 5 doesn't. Yet both require knowing the generic parameters of ctx to some extent (for the cast to be valid in case 4 and the function pointer call in case 5).

Is there a general "rule" of how closure type inference works that prevents cases 5 and 6 from working no matter what, and does anyone know if the API or trait definitions could be modified to allow the same thing to be done, while allowing the failing cases to type infer successfully?

The compiler really wants to know the type of values in the presence of method calls (.), in ways that often defeat inference. But I'm afraid I can't cite or tell you the exact "rules" as they exist today. Here's an old issue but there are many others. Here's another that mentions the "dot operator". I know I've seen other comments in passing, but it's hard to search for.

The difference between 7/8 and 6 is presumably the possibility of coercion during the assignment.

I agree case 4 is confusing considering the others. I'm pretty sure that one is relying on "there's only one applicable trait implementation" fallback since this breaks it:

+unsafe impl<T, R, F> FnThunk<cc::C, fn(T) -> R> for (cc::C, F) where F: Fn(T) -> R {}

And that's a type of inference that many language designers wish they could remove (as it makes adding implementations a breaking change).


That's as far as I got looking into with what's going on. I'm afraid I don't have many ideas on how to improve the design offhand.

Are the implementors of FnPTr and the B in FnThunk<CC, B> always one of these?

unsafe extern "C" fn() -> R
unsafe extern "C" fn(T) -> R
unsafe extern "C" fn(T, U) -> R
unsafe extern "C" fn(T, U, V) -> R
// etc

Thanks for mentioning these existing issues, makes it easier to understand why this kind of type inference isn't possible. I really wish case 6 could be worked around as it's a very common one (you have a struct holding a particular Hook, so it's bare function signature is already typed, and want to construct it).

Are the implementors of FnPTr and the B in FnThunk<CC, B> always one of these?

When it comes to built-in implementors, then yes. closure-ffi implements these automatically for functions of up to 12 arguments, with different calling convention marker types depending on the current platform. However, downstream crates can also provide implementations. This is necessary to support higher-kinded bare functions, e.g. for<'a> unsafe extern "C" fn(&'a T) -> &'a U, in which case a procedural macro is provided.

That's as far as I got looking into with what's going on. I'm afraid I don't have many ideas on how to improve the design offhand.

Now that I think of it, a builder pattern for Hook might be able to somewhat circumvent the awkwardness of specifying the context type via turbofish for case 5/6. Since the user might already have the hook's target address as a fully typed bare function pointer, having a builder method like target(mut self, fun: B) should allow "pinning" the bare function type before the closure is provided. Another method target_addr(mut self, addr: usize) could also be provided when type inference based on the closure is sufficient.

After some more messing around I did find a way to limit the type annotations to HookCtx<_>, which would make the constructor look like

let hook = Hook::with_context(cc::C, |ctx: HookCtx<_>| move |arg: u32| -> u32 {
     ctx.call_original((arg,))
});

The trick lies in avoiding the dot operator, as you mentioned. If we instead call a generic method on HookCtx, the type inference algorithm only needs to know that ctx is a HookCtx, and is able to infer the generic parameters that make the call valid.

Doing this will require some changes to the API -- in particular, a trait like this will be needed:

unsafe trait FnPtr: Clone + Copy {
    type Args<'a, ..., 'max>;
    type Ret<'a, ..., 'max>;
    
    unsafe fn call<'a, ..., 'max>(self, args: Self::Args<'a, ..., 'max>) -> Self::Ret<'a, ..., 'max>;
}

where 'a, ..., 'max is a fixed number of distinct lifetimes (3 would be a good choice I think). Making the associated types GATs should make retaining (partial) higher-kinded bare function support possible.