Safe Foreign Callouts from Racket to Swift

In anticipation of working on the Windows & Linux versions of Franz, I've wanted to move its auto-update implementation from Swift into Franz' Racket core. The reason I implemented the auto-update code in Swift in the first place is because of the way the Swift part normally communicates with the Racket part: the core Racket code runs in its own thread and the Swift part communicates with it asynchronously via pipes. So, until a couple of days ago, I didn't have an easy way for Racket code to trigger the execution of Swift code on its own.

All of the code that handles embedding Racket inside Swift, code generation and the general communication mechanism is open source and lives in Noise, so that's where you can find the full implementation of the approach I describe in this post (specifically, commits 0a585be and 2f6c37e).

Low Level Bits

Swift has its own calling convention, but it supports declaring procedures (but not closures) as using the C calling convention via the @convention(c) attribute. For example:

var add1: @convention(c) (Int) -> Int = { x in x + 1 }

This attribute makes it so that you can transparently pass such procedures around in places where you would normally store C function pointers. In my case, though, I didn't want to have to write any C code to support this functionality. Instead, I needed to be able to get the raw pointer addresses of the procedures so that I could serialize them and send them (over the aforementioned pipes) to the Racket side. Thankfully, there is a way to do this in Swift, via unsafeBitCast:

let ptr = unsafeBitCast(add1, Optional<UnsafeRawPointer>.self)
let addr = Int(bitPattern: ptr!)

With a raw pointer in hand, all I have to do is make an RPC to the Racket side to tell it to register a callout at that pointer's address. The Racket side can then take that address and construct a foreign procedure using its FFI facilities:

(require ffi/unsafe)

(define add1-type
  (_func _int -> _int))       ;; 1
(define add1
  (let ([p (malloc _intptr)]) ;; 2
    (ptr-set! p _intptr addr) ;; 3
    (ptr-ref p add1-type)))   ;; 4

There's no direct way to convert an address to a pointer using the FFI library. Instead, I have to allocate a bit of memory (2), write the address to that memory (3) and then read the address out as a foreign procedure (4). Additionally, I have to know what the signature of that procedure is (1) to be able to call it later. 1

Putting these bits together, I came up with a small abstraction on the Racket side to wrap the FFI code needed to turn a raw address into a foreign procedure:

(struct callout-box (type [proc #:mutable])
  #:property prop:procedure (λ (b . args)
                              (define proc (callout-box-proc b))
                              (unless proc
                                (error 'callout-box "procedure not installed"))
                              (apply (callout-box-proc b) args)))

(define (make-callout-box type)
  (callout-box type #f))

(define (callout-box-install! b addr)
  (define p (malloc _intptr))
  (ptr-set! p _intptr addr)
  (set-callout-box-proc! b (ptr-ref p (callout-box-type b))))

And, on the Swift side, I devised a little interface for installing arbitrary callbacks (on the Swift side) as callouts (on the Racket side) by using a trampoline (some details, such as locking around callbacks, elided for brevity):

public func installCallback(id: UInt64, proc: @escaping (Data) -> Void) -> Future<String, Void> {
  callbacks[id] = proc
  let ptr = unsafeBitCast(callbackHandler, to: Optional<UnsafeRawPointer>.self)
  let addr = Int(bitPattern: ptr!)
  installCallback(id, addr)  // RPC to Racket

fileprivate var callbacks = [UInt64: (Data) -> Void]()
fileprivate let callbackHandler: @convention(c) (UInt64, Int, UnsafePointer<CChar>) -> Void = { id, len, ptr in
  let data = ...  // based on len and ptr
  let proc = callbacks[id]

Whenever installCallback is called with a closure, it stores the closure in a global hash and calls an RPC on the Racket side to register the callbackHandler as the foreign procedure for that closure. The callback handler ends up always being the same, which is a little wasteful, but this keeps the implementation really straightforward so I'm not too bothered by it.

Mid Level Bits

You've probably noticed the id argument to installCallback. The Racket and Swift sides need to sync on these ids to know which callout connects to with callback. So, on the Racket side there is a syntactic form for declaring callouts:

(define-callout (hello-cb [name : String] [age : Varint]))

The callouts themselves can have arbitrary arguments and the data is automatically serialized when a callout procedure is executed, which is why the callback handler's type contains a size and a data pointer in addition to the callback id. We'll get to how this works on the Swift side toward the end of the article.

The implementation of define-callout is fairly straightforward. It starts with the well-known signature for callout handlers (the prototype of callbackHandler):

(define callout-type
  (_func _int _size _bytes -> _void))

Following that, there are some structure definitions to keep track of the callout metadata at runtime, a global registry for this metadata (indexed by callout id), and a helper function to perform the callouts:

(struct callout-arg (name type))
(struct callout-info ([id #:mutable] name args cbox))
(define callout-infos (make-hasheqv))

(define (do-callout info arg-pairs)
  (define id (callout-info-id info))
  (define cbox (callout-info-cbox info))
  (define bs
     (lambda (out)
       (for ([p (in-list arg-pairs)])
         (write-field (car p) (cdr p) out)))))
  (cbox id (bytes-length bs) bs))

The arg-pairs argument to do-callout is a list of cons pairs that contains the serializable type of each argument and the argument's runtime value. It takes those arguments, serializes them into a byte string and then executes the callout using the callout's id and the serialized data.

The definition of define-callout itself is as follows (with a couple small details simplified):

(define-syntax (define-callout stx)
  (syntax-parse stx
    #:literals (:)
    [(_ (name:id [arg-name:id : arg-type:expr] ...+))
     #:fail-unless (valid-name-stx? #'name)
     "callout names may only contain alphanumeric characters, dashes and underscores"
         (define (name arg-name ...) ;; 1
           (do-callout info (list (cons (->field-type 'Callout arg-type) arg-name) ...)))
         (define args
           (for/list ([n (in-list (list 'arg-name ...))]
                      [t (in-list (list arg-type ...))])
             (callout-arg n (->field-type 'Callout t))))
         (define cbox
           (make-callout-box callout-type))
         (define info ;; 2
           (callout-info #f 'name args cbox))
         (hash-set! callout-infos (next-callout-id!) info) ;; 3

Every use of define-callout expands to a definition of a procedure with the given name (1) that delegates to the do-callout helper when it itself is called and a metadata definition for the callout that is registered with the global registry (2, 3).

Finally, the RPC to install these callbacks that I mentioned at the end of the first section looks like this:

(define-rpc (install-callback [internalWithId id : UVarint]
                              [andAddr addr : Varint])
  (define cbox (callout-info-cbox (hash-ref callout-infos id)))
  (callout-box-install! cbox addr))

When applied, it looks up the runtime info for the callout with the given id, extracts its box and installs the procedure at that address into the box.

High Level Bits

You might be wondering why the callout-info needs to remember the argument types for the callout, since we never used them again above. This leads us to the final piece of this system, namely the code generation part.

Noise generates Swift code to handle data type serialization and deserialization, RPCs and, now, callouts. To do this, it uses that same runtime callout metadata described above.

When generating the Backend class for a project, it produces methods for all the RPCs and then it turns to callouts, the code for which looks like this:

(define sorted-callout-ids (sort (hash-keys callout-infos) <))
(for ([id (in-list sorted-callout-ids)])
  (match-define (callout-info _ name args _cbox)
    (hash-ref callout-infos id))
  (define proc-name (~name name))
  (define proc-type
    (format "@escaping (~a) -> Void"
             (map (compose1 swift-type callout-arg-type) args)
             ", ")))
  (fprintf out "~n")
  (fprintf out "  public func installCallback(~a proc: ~a) -> Future<String, Void> {~n" proc-name proc-type)
  (fprintf out "    return NoiseBackend.installCallback(id: ~a, rpc: self.installCallback(internalWithId:andAddr:)) { inp in~n" id)
  (fprintf out "      var buf = Data(count: 8*1024)~n")
  (fprintf out "      proc(~n")
  (define last-idx (sub1 (length args)))
  (for ([(arg idx) (in-indexed (in-list args))])
    (match-define (callout-arg _name type) arg)
    (define maybe-comma (if (= idx last-idx) "" ","))
    (fprintf out " inp, using: &buf)~a~n" (swift-type type) maybe-comma))
  (fprintf out "      )~n")
  (fprintf out "    }~n")
  (fprintf out "  }~n"))

That is, for every known callout, it generates a Swift method named installCallback(calloutName:). To give a concrete example, here is what the installer for the hello-cb example from earlier in this article would look like:

public func installCallback(helloCb proc: @escaping (String, Varint) -> Void) -> Future<String, Void> {
  return NoiseBackend.installCallback(id: 0, rpc: self.installCallback(internalWithId:andAddr:)) { inp in
    var buf = Data(count: 8*1024)
    proc( inp, using: &buf), inp, using: &buf)

Which you would use from the Swift side like so:

Backend.shared.installCallback(helloCb: { name, age in
  print("hello \(name), I hear you're \(age) years old!")

And you would call from the Racket side like so:

(hello-cb "Bogdan" 30)

And you wouldn't need to worry about most of the details I've written about above.

  1. Sam Phillips pointed out on the Racket Discord that there is actually a helper for this in ffi-lib, namely cast. So this let can be replaced with (cast addr _intptr add1-type).