Making an async (Actor) call from a C callback?

I've written a simple Swift wrapper around libmodbus, and it generally works fine. It serializes calls to the underlying C library on a Dispatch Queue (which then ends up being read/write operations on a serial bus).

Now I’m trying to add support for a libmodbus call back function for setting a custom RTS bit. In my case, when this code is running on a Raspberry Pi, I need to manually set or reset a GPIO to properly enable the driver IC. libmodbus provides support for this by allowing the client to specify a (C) callback function that tells it when to enable the driver.

In my wrapper, I’m trying to implement this with a delegate pattern, but there are two problems. One I can solve (the library doesn't provide for a way to pass a client context in the callback), so I don't technically have access to the delegate in the callback. This I can fix by enhancing libmodbus.

The more serious problem is that I need to make a synchronous call in the callback to an Actor method (my delegate is an Actor), from this decidedly non-async context.

Here’s the code I initially wrote:

public protocol MODBUSContextDelegate : AnyObject
{
	func tx(enable inEnable: Bool, for inCTX: MODBUSContext) async
}
public final class MODBUSContext
{
	init(…)
	{
		…
		if inCustomRTS
		{
			modbus_rtu_set_custom_rts(self.ctx)
			{ inCtxPtr, inOn in
				self.delegate?.tx(enable: inOn != 0, for: self)
//				^ 'async' call in a function that does not support concurrency
			}
		}
	}
}

One solution might be to not use the delegate pattern here, but to just pass a closure to be called synchronously.

But is there a way to wrap this in Task {} and then wait for that task to complete?

But is there a way to wrap this in Task {}

This bit is easy.

and then wait for that task to complete?

This bit is the challenge. The standard async-to-sync [anti-]pattern — that is, a Dispatch semaphore — lets you do this, but it comes with caveats. What thread is running this callback? Will that thread be happy if you block it for (potentially) a very long time? And does having that thread blocked here open you up to deadlocks elsewhere?

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

Yes, this thread is happy to be blocked. In fact, I'm calling the libmodbus function in this context:

        try await withCheckedThrowingContinuation
        { (inCont: CheckedContinuation<Void, Error>) -> Void in
            self.workQ.async
            {
                do
                {
                    self.deviceID = inDeviceID
                    try self.write(address: inAddr, value: inVal)
                    inCont.resume()
                }
                
                catch (let e)
                {
                    inCont.resume(throwing: e)
                }
            }
        }

(self.write(address: inAddr, value: inVal) is a simple wrapper around modbus_write_registers().) Inside libmodbus, it calls my callback synchronously, then calls POSIX write(), then calls my callback again, to give me an opportunity to assert RTS before the write and deassert it after.