Skip to content

Handling callbacks with the LibraryImport source generator #82017

@f2bo

Description

@f2bo

I'm looking for guidance on how to handle callbacks from native code when using the LibraryImport source generator. I may have missed it but I didn't see this particular usage covered in the documentation.

Whenever the parameters to a callback are not blittable, it becomes necessary to intercept it, marshal the parameters back to their managed representation, and then invoke the managed callback with the appropriate parameters. As far as I can tell, this is not something currently handled by the source generator.

For example, given a hypothetical native API that opens a connection and takes a callback to be notified of connection state changes.

typedef struct ConnectionState
{
    int status_code;
    char* error_message;
} ConnectionState;

typedef void (*ConnectionStateChange)(ConnectionState* state);

ConnectionHandle OpenConnection(ConnectionStateChange callback);

This could be handled by the following managed code.

class ConnectionHandle : SafeHandle
{
   ...
}

public struct ConnectionState
{
    public int status_code { get; set; }
    public string error_message { get; set; }

    internal unsafe struct Native
    {
        public int status_code;
        public sbyte* error_message;
    }
}

delegate void ConnectionStateChange(ConnectionState connectionState);

[LibraryImport("some_lib")]
static partial ConnectionHandle OpenConnection([MarshalUsing(typeof(ConnectionStateChangeMarshaller))] ConnectionStateChange callback);

where the callback parameter of the OpenConnection function is marshalled with the following custom marshaller. By the way, the marshaller below generates warning SYSLIB1057 about not having a Free method that doesn't seem right as there's nothing to be freed in this case. Nevertheless, the code compiles and works as expected when the warning is suppressed.

[CustomMarshaller(typeof(ConnectionStateChange), MarshalMode.ManagedToUnmanagedIn, typeof(ConnectionStateChangeMarshaller))]
internal unsafe struct ConnectionStateChangeMarshaller
{
    private ConnectionStateChange _callback;

    // intercepts the callback with a native representation of the connectionState parameter,
    // converts it to its managed representation, and invokes the original callback
    private unsafe void Interceptor(ConnectionState.Native* connectionState)
    {
        ConnectionState marshaller = new();
        try
        {
            marshaller.FromUnmanaged(connectionState);
            _callback(marshaller.ToManaged());
        }
        finally
        {
            marshaller.Free();
        }
    }

    public void FromManaged(ConnectionStateChange managed) => _callback = managed;

    public delegate* managed<ConnectionState.Native*, void> ToUnmanaged()
    {
        Delegate nativeCallback = Interceptor;
        GCHandle.Alloc(nativeCallback);		// HACK: to keep delegate alive
        return (delegate* managed<ConnectionState.Native*, void>) Marshal.GetFunctionPointerForDelegate(nativeCallback);
    }
}

Note the need to keep a reference to the original _callback so that the interceptor can invoke it once it has unmarshalled the parameters, which basically rules out using a static method for the interceptor and requires using a delegate instead. Moreover, given that the callback can be invoked at any time, the lifetime of the delegate cannot be tied to that of the marshaller. For my quick test, and to prevent the delegate from being garbage collected, I allocated a GCHandle that is basically leaked, but this was only a temporary solution.

Prior to the LibraryImport generator, I used code similar in structure to that generated by the source generator where I also intercepted the callback using a delegate. However, in that case, the code had visibility of all the parameters involved in the call and of its return value, so I could store a reference to the delegate in a member of the ConnectionHandle returned by the call to ensure that the delegate was kept alive for as long the connection was open.

However, it seems a custom marshaller designed for the LibraryImport generator has no context and is not aware of other parameters or of the return value of the call where it is being used. Is there a way to associate the delegate reference to the lifetime of some other object?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions