Skip to content

Latest commit

 

History

History
219 lines (161 loc) · 15 KB

README.md

File metadata and controls

219 lines (161 loc) · 15 KB

NIX

Not to be confused with the Nix package manager, NIX (as in "UNIX" without the "U") is a thin Swift wrapper around the POSIX system call API provided by Darwin and Linux to make working with that API easier and safer in Swift while preserving the basic feel of the POSIX API.

It provides improved type safety (for example flags are specific OptionSets rather than Int32 to prevent illegal values from being passed), attempts to remove the need for the caller to explicitly use UnsafePointers, and separates normal return values from error indicators by returning either NIX.Error? or a Result<T, NIX.Error>. I've specifically chosen not to use exceptions for error handling, because it deviates too much from the way the POSIX API is designed.

I'm making it available for others to use, but I've created it for my own use, and am improving and updating it when I have the need, so it's a work-in-progress, and is likely to be for a long time, given how large the POSIX API is.

If the functionality you're looking for is missing, but is provided for by POSIX, or normally provided on Unix-like operating systems, please let me know, or even better contribute.

At the moment NIX mostly centers around socket functionality, but the intention is to include more and more of the POSIX API over time.

Design

One of the core principles of NIX is to maintain, as much as possible, the POSIX.1 interface, while improving type-safety and error handling to prevent common mistakes. Those improvements require altering the interface somewhat, so NIX is not just a drop-in overlay over POSIX.1, but it should feel familiar to Swift programmers familiar with POSIX.1 in C, making it easy to adopt.

NIX adopts some consistent standards to accomplish this.

Error Handling

POSIX.1, intended for use in the C programming language, handles errors by overloading the meaning of a function's return value, and setting a global errno for that exact error. Functions that return pointers indicate failure by returning NULL, which numerically is 0 in C. On the other hand, functions that return integers, indicate failure by returning -1, which is ~0 in C... exactly the inverse of the pointer return. Furthermore, functions whose integer return value is just a boolean success or fail return 0 to indicate success and -1 to indicate failure. While this makes sense given the constraints of using integers for the return value, it is counter-intuitive. Normally 0 is FALSE which one would naturally associate with failure, and non-zero is normally TRUE which one would naturally assocate with success.

In addition it's easy for the naive programmer, or even an experienced one who isn't having a good day, to forget to check for errors, both because C doesn't require using returned values, and because the error indicator is of the same type as the normal, successful return value. The compiler can't tell the difference, and so can't give the programmer any feedback that something is amiss.

And if an error occurs, one has to check the global errno value to get the actual error.

You can see why this is error-prone.

NIX uses Swift's features to overcome these deficienciy and divides these into two categories:

  • Functions that simply return success or failure
  • Functions that return a value that use a special value to indicate an failure, otherwise it's a success, and the value has some other use.

In both cases, NIX explicitly returns a NIX.Error, which already contains the value from errno, but the particular method of returning it depends on which category the underlying POSIX function falls into, as described below. Further NIX never defines any functions as @discardableResult, so the caller has to either check it or explicitly discard the result with Swift's anonymous assignment (ie. _ = foo()).

NIX.Error conforms both to Swift's Error protocol, so you can throw it from your code, if you wish, and to CustomStringConvertible which obtains the error description internally by calling the POSIX strerror() function.

Success or Fail Functions

For functions whose return value serves no purpose other than to indicate success or failure, NIX returns a NIX.Error?. This makes it obvious that nil indicates "no error" (ie. success) and immediately provides the actual error on failure. For example:

if let error = NIX.close(file) {
    fatalError("Failed to close file: \(error)")
}

Value or Error functions

Functions that, on success, return a value that means something other than merely success return a Swift Result<Value, NIX.Error>, where Value is the type of the value being returned on success. For example:

let file: FileDescriptor
switch NIX.open(filename, .readWrite)
{
    case .success(let fd): file = fd
    case .failure(let error): fatalError("Failed to open file: \(error)")
}

Distinct Parameter and Return Value Types

Another source of errors in the POSIX API is the many overloaded uses of C's int even when it doesn't possibly indicate an error. It is used for file descriptors, option flags, sizes of data, etc... From the C compiler's point of view, they are all interchangeable. In Swift those ints become Int32 but their uses are as equally indistinguishable as they are in C. That means it's easy to use a value obtained from one context, where it has a particular meaning, in another context, where the original meaning is completely invalid. For example in POSIX, both file descriptors and sockets are ints, but one should not use a file descriptor for bind(), nor should one use a socket for lseek(). The compiler can't do anything to help you out, because from its perspective, one int is as good as another. If you're lucky, the bug is found early at runtime when the system can check the value you passed to bind(), for example, and detects that it's not a valid socket, and so bind() would fail.

And yet sometimes the ints that represent different things are interchangeable. For example one can call close(), read() or write() with either a file descriptor or a socket.

We'd like the compiler to help us sort this stuff out at compile-time, and that comes down to creating distinct types for the different meanings, so that is exactly what NIX does.

For example, NIX defines distinct FileDescriptor and SocketDescriptor types. Since NIX.bind() only accepts a SocketDescriptor, and NIX.open() only returns a FileDescriptor, the compiler won't let you use the value returned by NIX.open() in a call to NIX.bind(). But NIX.close() accepts any IODescriptor, a protocol to which both SocketDescriptor and FileDescriptor conform, so it can accept either.

Additionally NIX uses distinct enum types for mutually exclusive options (for example setting a socket domain). Each function uses a specific type for its options. That helps you in two ways. The first is that you can't use an invalid option for the function. The second is that IDE autocompletion helps you discover what the valid options are. Additionally, NIX deviates from the POSIX naming for options to give them more meaningful names. For example POSIX's O_RDWR is .readWrite.

Another overloaded use of int in POSIX is for option flags that can be combined with bitwise-OR. Normally only a subset of the bits are meaningful. NIX handles this by providing specific types that conform to OptionSet. You can bitwise-OR them together just as you would with the POSIX flags, but invalid bits are automatically filtered out. Alternatively you can use the typical OptionSet array syntax to combine them. Often only a subset of the bits are valid for use in a particular function. For example while chmod() allows a set of flags that includes S_ISVTX, which becomes .saveSwappedText in NIX, that particular bit is not valid for open() when creating a file. NIX defines separate types for these, so NIX.chmod() (not currently implemented) uses FileAccessMode, while open uses OpenFileAccessMode which does not incude the .saveSwappedText option, so you can't use it thinking it will work, and then have to dig to man pages to find out why it doesn't.

The NIX.open() example demonstrates another type of safety NIX introduces. The POSIX definition for that function allows an O_CREAT flag to be set in order to create a file, and the mode parameter is only required if that bit is set. So opening a file with the same POSIX function requires only three parameters when not creating the file, and requires four parameters when creating the file. NIX avoids that confusion by excluding creation bit from NIX.open(_:_:_:)'s flags, and instead provides an alternate version, NIX.open(_:_:_:create:) to use for file creation. The same approach is taken for NIX.openat(_:_:_:_:). Both also separate the read/write access options from the other flags by using a different type for them in a separate parameter.

Eliminating Pointer Parameters

Obviously one expects a C-based interface, as POSIX.1 is, to use pointers. Unfortunately, pointers open up tons of opportunities for errors. NIX has to use pointers to interact with the underlying POSIX API, but it tries to avoid exposing those pointers to the caller, so you can write your Swift code without worrying about those details. Besides, while Swift does provide pointers in the Unsafe...Pointer family of types, those are especially awkward to use, and that's by design. The language is attempting to discourage their use, while acknowledging that sometimes they are necessary. Also POSIX.1 provides for some functions, like readv() that use pointers in a way that would be exceedingly tricky to get right consistently in Swift. NIX handles that detail for you. For example, NIX.readv() takes an inout array of Data instances, and internally steals pointers to their data to build the iovec array that POSIX's readv expects. In doing so, it has to use some normally unsafe techniques to defeat Swift's pointer type invalidation, and is only safe because it ensures that the pointers don't escape a scope in which they are known to be valid. This achieves nearly the same performance as directly using readv() in C would have. The same logical effect could have been achieved without the pointer stealing by emulating readv instead of using it directly, but that would require multiple calls to read, which would dramatically alter its performance. NIX tries to do its job while maintaining a 1:1 ratio of NIX-to-POSIX calls.

Where POSIX uses pointers to represent some arbitrary block of bytes, such as a read buffer, NIX uses Foundation's Data for the block of bytes, unless the block of bytes is supposed to be C string, in which case it uses a Swift String. When the pointer is to a single const instance of a type, NIX uses a Swift value. For pointers to single non-const values, NIX uses inout parameters. When the pointers are used for an array of instances some type, NIX uses a Swift Array with elements of that type.

Example Code

As an example, here's a simple echo server (IPv6) in all its POSIX-level glory, written using NIX:

import NIX
import Foundation

func echoServerExample()
{
    let listenSocket = setUpListenerSocket(onPort: 2020)
    defer { _ = NIX.close(listenSocket) }

    print("Echo server started...")

    let dispatchQueue = DispatchQueue(label: "\(UUID())", attributes: .concurrent)

    while let peerSocket = acceptAConnection(for: listenSocket)
    {
        dispatchQueue.async {
            clientSession(for: peerSocket)
        }
    }
}

func setUpListenerSocket(onPort port: Int) -> SocketIODescriptor
{
    let listenSocket: SocketIODescriptor
    switch NIX.socket(.inet6, .stream, .ip)
    {
        case .success(let sock): listenSocket = sock
        case .failure(let error):
            fatalError("Could not create listener socket: \(error)")
    }

    let socketAddress = SocketAddress(ip6Address: .any, port: port)

    if let error = NIX.bind(listenSocket, socketAddress) {
        fatalError("Could not bind listener socket: \(error)")
    }

    if let error = NIX.listen(listenSocket, 100) {
        fatalError("Could not listen on listener socket: \(error)")
    }
    
    return listenSocket
}

func acceptAConnection(for listener: SocketIODescriptor) -> SocketIODescriptor?
{
    var peerSocket: SocketIODescriptor
    switch NIX.accept(listener)
    {
        case .success(let sock): peerSocket = sock
        case .failure(let error):
            fatalError("Accept failed on listener socket: \(error)")
    }
    
    /*
     Some code could be put here to allow terminating the listener loop by
     returning nil.  For this simple example, we don't do that.
     */
    
    return peerSocket
}

func clientSession(for peerSocket: SocketIODescriptor)
{
    defer { _ = NIX.close(peerSocket) }
    
    var readBuffer = Data(repeating: 0, count: 1024)

    while let peerMessage =
            getPeerMessage(from: peerSocket, using: &readBuffer)
    {
        if peerMessage.isEmpty { continue }
        
        guard let response = makeResponse(for: peerMessage),
              sendResponse(response: response, to: peerSocket)
        else { break }
    }
}

func getPeerMessage(
    from peerSocket: SocketIODescriptor,
    using readBuffer: inout Data) -> Data?
{
    switch NIX.read(peerSocket, &readBuffer)
    {
        case .success(let bytesRead):
            if bytesRead == 0 {
                print("Peer closed connection")
                return nil
            }
            
            return Data(readBuffer[..<bytesRead])
            
        case .failure(let error):
            if error.errno == HostOS.EAGAIN { return Data() }
            print("Error reading from peer socket: \(error)")
            return nil
    }
}

func makeResponse(for message: Data) -> String?
{
    guard var peerStr = String(data: message, encoding: .utf8) else
    {
        print("Peer message is invalid string: \(message)")
        return  "Huh?"
    }
    
    if peerStr.last == "\n" { peerStr.removeLast() }
    
    if peerStr.lowercased() == "quit"
    {
        print("Client requested quit")
        return nil
    }
    
    print("Peer message received: \"\(peerStr)\"")
    return "You said, \"\(peerStr)\"\n"
}

func sendResponse(response: String, to peerSocket: SocketIODescriptor) -> Bool
{
    switch NIX.write(peerSocket, response.data(using: .utf8)!)
    {
        case .success(let bytesWritten):
            if bytesWritten != response.count {
                print("Not all bytes were written to peer socket")
            }
        case .failure(let error):
            print("Error writing to peer socket: \(error)")
            return false
    }
    return true
}

Obviously, one could write it much more succinctly using a higher level library, but that misses the point, which is that if directly using Darwin's (or Linux's) POSIX calls, much of the code would have to be wrapped in withUnsafePointer closures, and it's easy to forget to check return values. With NIX functions, if an error occurs, it can't be mistaken for a good return value, because it's a completely different type.