scala-uv v0.0.2

I've published v0.0.2 of scala-uv, "Artisanal, handcrafted Scala Native bindings for libuv".

Documentation

There's now some API docs, with links to the corresponding libuv docs for each function. The readme also now has details about how scala-uv represents libuv types like uv_handle_t in Scala.

Type safety

As previously discussed, structures used in C APIs are often "abstract", meaning their precise layout is not documented, and often vary across platforms. In Scala Native, we use a Ptr[Byte] to point to such structures, with all the code that actually accesses fields of the structures is implemented in C.

Previously I had used type aliases for such types. This works, but gives worse type safety than C, as you can freely pass a uv_tcp_t* where a uv_udp_t* is required, for example. But opaque types solve the problem nicely:

opaque type Handle = Ptr[Byte]

opaque type TcpHandle <: Handle = Ptr[Byte]

opaque type UdpHandle <: Handle = Ptr[Byte]

This also gives us proper subtyping, where TcpHandle can be passed to any function requiring Handle, whereas in C we'd have to manually "upcast" it. Nice!

To make this work, we need one bit of boilerplate so that Scala Native knows how to deal with our opaque type:

object Handle {
  given Tag[Handle] = Tag.Ptr(Tag.Byte)
}

Error handling

Allocating memory or acquiring resources in C is a painful business. When using callbacks you often free memory and close resources in another function that is called by libuv after whatever operation you're doing has finished. But there are typically failure conditions that can crop up that will prevent running the operation, meaning your callback will never be called. In such cases, you need to free whatever you've already allocated right away.

Doing this just with if statements is quite tedious, and in C goto is often used for error handling as a poor man's try/catch. In Scala we have actual exceptions, so scala-uv provides checkErrorThrowIO() to turn negative error codes into thrown exceptions. This allows us to collect our error handling in one place, but requires maintaining some vars to track how far we got:

def onClose: CloseCallback = (_: Handle).free()

def onNewConnection: ConnectionCallback = {
  (handle: StreamHandle, status: ErrorCode) =>
    val loop = uv_handle_get_loop(handle)
    var clientTcpHandle: TcpHandle = null
    var initialized = false
    try {
      status.checkErrorThrowIO()
      clientTcpHandle = TcpHandle.malloc()
      uv_tcp_init(loop, clientTcpHandle).checkErrorThrowIO()
      initialized = true
      uv_handle_set_data(clientTcpHandle, handle.toPtr)
      uv_accept(handle, clientTcpHandle).checkErrorThrowIO()
      uv_read_start(clientTcpHandle, allocBuffer, onRead)
        .checkErrorThrowIO()
      ()
    } catch { 
      case e: IOException =>
        if (initialized)
          // note the onClose callback will free the handle
          uv_close(clientTcpHandle, onClose)
        else if (clientTcpHandle != null)
          clientTcpHandle.free()
        setFailed(exception.getMessage())
    }
}

To make this a little easier, scala-uv also offers UvUtils.attemptCatch, which allows you to register whatever cleanup code you like using UvUtils.onFail. If an exception is thrown from the attemptCatch block, then any cleanup code that has been registered so far is then run, in order from the most recently registered first:

def onClose: CloseCallback = (_: Handle).free()

def onNewConnection: ConnectionCallback = {
  (handle: StreamHandle, status: ErrorCode) =>
    val loop = uv_handle_get_loop(handle)
    UvUtils.attemptCatch {
      status.checkErrorThrowIO()
      val clientTcpHandle = TcpHandle.malloc()
      uv_tcp_init(loop, clientTcpHandle)
        .onFail(clientTcpHandle.free())
        .checkErrorThrowIO()
      UvUtils.onFail(uv_close(clientTcpHandle, onClose))
      uv_handle_set_data(clientTcpHandle, handle.toPtr)
      uv_accept(handle, clientTcpHandle).checkErrorThrowIO()
      uv_read_start(clientTcpHandle, allocBuffer, onRead)
        .checkErrorThrowIO()
      ()
    } { exception =>
      setFailed(exception.getMessage())
    }
}

There is also UvUtils.attempt for situations where you want to run cleanup code, but still want the exception to propagate up. Cleanup code registered using UvUtils.onFail only runs if an exception is thrown, whereas cleanup code registered using UvUtils.onComplete runs whether the attempt block succeeds or fails.

Note that the above example is a little tricky. If the uv_tcp_init call fails, we want to free the handle we've allocated. But once the initialisation succeeds, then we will use uv_close to close the handle, which will also free the memory allocated for the handle (in the onClose callback). So we do not want to execute both uv_close and clientTcpHandle.free(). In the first version, this is achieved by only calling free if initialized was false. In the second version, we attach the call to free only to the result of uv_tcp_init without registering it as a cleanup operation for the attemptCatch block.