After wanting to for some time, I decided to finally get around to putting together a typed layer over the Haskell network library. This post goes into the techniques I used to make that happen, how it works and looks, and what the current short-comings are.
Singletons and phantom types are involved! For the code written in this post, the following language extensions are assumed to be enabled:
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE EmptyDataDecls #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}
Working with sockets was often a bit of an adventure. There’s a particular order to things, which, while well-documented, wasn’t always clear. For example, to create a client socket that communicate using TCP, you’d have to:
Haskell code to achieve this looks like this:
> import Network.Socket
> import qualified Network.Socket.ByteString as NSB
> serverAddress = SockAddr 2291 (tupleToHostAddress (127,0,0,1))
> socket <- socket AF_INET Stream defaultProtocol
> connect socket serverAddress
> send socket "fish"
> bs <- recv socket 32
> close socket
To create a server socket is a bit more involved. The steps are the same up to the point where we allocate a network address structure. After that, the steps are:
accept
connectionsThe code looks like this, without a loop:
> import Network.Socket
> import qualified Network.Socket.ByteString as NSB
> serverAddress = SockAddr 2291 (tupleToHostAddress (127,0,0,1))
> socket <- socket AF_INET Stream defaultProtocol
> bind socket serverAddress
> listen socket 1
> (client, _) <- accept socket
> bs <- recv client 32
> send client bs
> close client
> close socket
While seemingly relatively straight-forward (but perhaps only if one has read network socket documentation a lot), there’s some room for error. For example, one might:
And if we consider the broader socket API, there’s also room for error around sockets that have shutdown their ability to send or receive. There’s also some very particular rules around sending or receiving with UDP sockets when using the recvFrom
/sendTo
interfaces.
Lots of details. All documented in a few places. But!! We can do a little better with types. That’s my motivation for this effort, and the rest of this post goes into the design and implementation of such an API.
Working with the socket API, there’s at least 4 bits of intrinsic state to them that determine how they work:
Here’s a snippet of the current socket APIs type signatures:
socket :: Family -> SocketType -> ProtocolNumber -> IO Socket
connect :: Socket -> SockAddr -> IO ()
bind :: Socket -> SockAddr -> IO ()
listen :: Socket -> Int -> IO ()
accept :: Socket -> IO (Socket, SockAddr)
shutdown :: Socket -> ShutdownCmd -> IO ()
close :: Socket -> IO ()
send :: Socket -> ByteString -> IO Int
recv :: Socket -> Int -> IO ByteString
sendTo :: Socket -> ByteString -> SockAddr -> IO Int
recvFrom :: Socket -> Int -> IO (ByteString, SockAddr)
What I’d like to point out is that once we call socket
, we have a Socket
, but our types tell us nothing about that Socket
. Further, when we call connect
or bind
or listen
or close
, we lose information. Changes are happening to that socket, but all we get back is an empty void, IO ()
. We know an effect has happened! But - we forget the nature of that effect.
IO ()
, for when you want to throw away information.
Fortunately, Haskell is one of those languages where we can track these sorts of changes in the types themselves. So, what would a type-enhanced socket type look like?
Here’s one possible design:
import qualified Network.Socket as NS
newtype
SSocket (f :: SocketFamily) (p :: SocketProtocol)
(s :: SocketStatus) (sh :: ShutdownStatus)
= SSocket NS.Socket
It’s nothing more than a newtype wrapper around the socket library, with several phantom types. They’re so-called phantom types, because they don’t affect the in-memory or run-time representation of our data structures. They spookily vanish once the program begins to run.
Now let’s look at the definitions for the each of these phantom types:
data SocketFamily
= Unix
| InetV4
| InetV6
data SocketProtocol
= Tcp -- Stream
| Udp -- Datagram
data SocketStatus
= Unconnected
| Bound
| Listening
| Connected
| Closed
data ShutdownStatus
= Available
| CannotReceive
| CannotSend
| CannotSendOrReceive
Nothing too surprising yet. This is still pretty cozy Haskell. Now, how do we get from that data type definition to a typed socket API? There’s the challenge.
Let’s consider the socket
function. It’s original form is:
If we just map the original socket function over to the new socket type, it’d look something like:
However, we know a bit more than that about the state of a newly created socket. This means we can refine our type signature a step further:
Initializing a socket always gives one with a socket status of Unconnected
and a shutdown status of Available
. And so we initialize our socket state machine! We’re using DataKinds
here to promote the data definitions to the type-level.
So what would the implementation look like? Here it is:
socket ::
Family -> SocketType -> ProtocolNumber
-> IO (SSocket f p 'Unconnected 'Available)
socket fam sockType protocolNum =
return (SSocket (socket fam sockType protocolNum))
That’s it!
However, there’s two shortcomings here:
We should be able to do such a thing. And to do that, we turn to a technique known as singletons! In the next section, we’ll work through the machinery needed to connect types and values.
Before we get to the details of the technique, we’ll start from the end. The final form of the socket
function type signature looks like this:
Since the last version presented, we’ve:
With singleton types, it was possible to do all this and still have a reasonable implementation. After all, if the user specifies what types they want for the family and protocol, shouldn’t we be able to produce the values that match that?
That’s dependently-typed programming in Haskell.
We’ll need four specific tools to connect types and values:
Before we delve into the details, here’s the full implementation of the socket
function in its final form:
socket :: forall f p.
(SockFam f, SockProto p) => IO (SSocket f p 'Unconnected 'Available)
socket =
let x = socketFamily (socketFamily1 :: SocketFamily1 f)
y = socketProtocol (sockProto1 :: SocketProtocol1 p)
in SSocket <$> (NS.socket x y NS.defaultProtocol)
I’ve at least six things to explain here:
socketFamily
socketFamily1
SocketFamily1
(yeah, dependent types in Haskell are a party of repetition!)socketProtocol
sockProto1
SocketProtocol1
(we really need to convince the type-checker of things)Let’s start with all the socket family biz. Keep in mind, we want to go from a type SocketFamily
given as f
to a value NS.SocketFamily
that the lower-level socket
function can consume.
First, a GADT mirror for the SocketFamily
data type:
data SocketFamily1 (f :: SocketFamily) where
SUnix :: SocketFamily1 'Unix
SInetV4 :: SocketFamily1 'InetV4
SInetV6 :: SocketFamily1 'InetV6
This gives us values that connect the type-level SocketFamily
to the value-level. That’s step one.
The next step, a type class to give us a unique value from a SocketFamily
type:
class SockFam (f :: SocketFamily) where
socketFamily1 :: SocketFamily1 f
instance SockFam 'Unix
where socketFamily1 = SUnix
instance SockFam 'InetV4
where socketFamily1 = SInetV4
instance SockFam 'InetV6
where socketFamily1 = SInetV6
Between the GADT and the type-class, we’ve come most of the way. The final step, is a forgetful function giving us our NS.SocketFamily
, so-called because it forgets its phantoms:
socketFamily :: SocketFamily1 f -> NS.Family
socketFamily = \case
SUnix -> NS.AF_UNIX
SInetV4 -> NS.AF_INET
SInetV6 -> NS.AF_INET6
And that’s a singleton! The steps, in summary:
This is the simplest version of the singleton technique. It involves a lot of repetitive code, to be sure. It’s also a topic of active research, with nifty ways of mirroring even more complex structures between the type- and value-levels.
For more details and a couple of papers, check out the singletons library, which uses template-haskell to automate a lot of this!
Now, we look at the socket protocol side:
data SocketProtocol1 (p :: SocketProtocol) where
STcp :: SocketProtocol1 'Tcp
SUdp :: SocketProtocol1 'Udp
class SockProto (p :: SocketProtocol) where
sockProto1 :: SocketProtocol1 p
instance SockProto 'Tcp
where sockProto1 = STcp
instance SockProto 'Udp
where sockProto1 = SUdp
socketProtocol :: SocketProtocol1 p -> NS.SocketType
socketProtocol = \case
STcp -> NS.Stream
SUdp -> NS.Datagram
It’s a nearly identical dance.
With those two pieces, we’ve fully implemented socket
:
socket :: forall f p.
(SockFam f, SockProto p) => IO (SSocket f p 'Unconnected 'Available)
socket =
let x = socketFamily (socketFamily1 :: SocketFamily1 f)
y = socketProtocol (sockProto1 :: SocketProtocol1 p)
in SSocket <$> (NS.socket x y NS.defaultProtocol)
That was An Effort.
In the next section, we’ll look at the rest of the typed socket API.
So far, we have a nifty socket data type and a means to initialize our socket. However, we still want to like, be able to do a lot more with those sockets. After all, the goal is to have a usable sockets library!
Here’s an overview of the remainder of the API that affects socket state. We’ll use this to detail the last few techniques:
connect ::
SockAddr f -> SSocket f 'Tcp 'Unconnected sh
-> IO (SSocket f 'Tcp 'Connected 'Available)
bind :: SockAddr f -> SSocket f p 'Unconnected sh -> IO (SSocket f p 'Bound sh)
listen :: Int -> SSocket f 'Tcp 'Bound sh -> IO (SSocket f 'Tcp 'Listening sh)
accept :: SSocket f 'Tcp 'Listening sh -> IO (SSocket f 'Tcp 'Connected sh, NS.SockAddr)
close :: Closeable s ~ 'True =>
SSocket f p s sh -> IO (SSocket f p 'Closed sh)
shutdownReceive ::
CanShutdownReceive sh s ~ 'True =>
SSocket f p s sh
-> IO (SSocket f p s (Shutdown sh 'NS.ShutdownReceive))
shutdownSend ::
CanShutdownSend sh s ~ 'True =>
SSocket f p s sh
-> IO (SSocket f p s (Shutdown sh 'NS.ShutdownSend))
shutdownBoth ::
CanShutdownBoth sh s ~ 'True =>
SSocket f p s sh
-> IO (SSocket f p s (Shutdown sh 'NS.ShutdownBoth))
The simplest operations here are connect
, listen
, bind
, and accept
. The implementations of these are strictly pass-throughs to the lower-level API, and all we do is update the types to reflect the changes. There’s still a bit of power here. For example, looking at bind
, we require that a socket be in an Unconnected
state before this’ll compile. This is where the safety of the API comes from - we place strong requirements on the data we introduce before we allow a change to be made.
Let’s look at close
:
There’s two new things at work here:
Closeable
, a type family (a type function)Closeable
itself is comfortable to write:
type family Closeable (s :: SocketStatus) :: Bool where
Closeable 'Unconnected = 'True
Closeable 'Bound = 'True
Closeable 'Listening = 'True
Closeable 'Connected = 'True
Closeable 'Closed = 'False
For every status a socket could be in, we indicate whether it makes sense to close it. Technically, there’s no harm in closing a socket that’s already been closed. But, we can prevent even that bit of waste by marking that as an invalid state.
If we try to close a socket that’s already been closed, we’ll get this sort of error:
> tcp4Socket >>= close >>= close
<interactive>:2:26: error:
• Couldn't match type ‘'False’ with ‘'True’
arising from a use of ‘close’
• In the second argument of ‘(>>=)’, namely ‘close’
In the expression: tcp4Socket >>= close >>= close
In an equation for ‘it’: it = tcp4Socket >>= close >>= close
It is not at all a particularly helpful type error. As of GHC 8.0, there’s a means to make type errors more helpful, but that technique is not covered in this post. For now, it’ll suffice to say - the error is prevented and highlighted.
To close up our discussion on close
, here’s the full implementation (which mirrors bind
and friends):
close :: Closeable s ~ 'True =>
SSocket f p s sh -> IO (SSocket f p 'Closed sh)
close (SSocket s) = NS.close s >> return (SSocket s)
Now, let’s talk about the shutdown commands:
shutdownReceive ::
CanShutdownReceive sh s ~ 'True =>
SSocket f p s sh
-> IO (SSocket f p s (Shutdown sh 'NS.ShutdownReceive))
shutdownSend ::
CanShutdownSend sh s ~ 'True =>
SSocket f p s sh
-> IO (SSocket f p s (Shutdown sh 'NS.ShutdownSend))
shutdownBoth ::
CanShutdownBoth sh s ~ 'True =>
SSocket f p s sh
-> IO (SSocket f p s (Shutdown sh 'NS.ShutdownBoth))
Four new type functions are introduced:
CanShutdownReceive
CanShutdownSend
CanShutdownBoth
Shutdown
The techniques are very similar to what we did for close
. Below are the relevant implementations for the first three type functions:
type family CanShutdownReceive (sh :: ShutdownStatus) (s :: SocketStatus) :: Bool where
CanShutdownReceive 'Available 'Connected = 'True
CanShutdownReceive 'CannotSend 'Connected = 'True
CanShutdownReceive 'Available 'Listening = 'True
CanShutdownReceive 'CannotSend 'Listening = 'True
type family CanShutdownSend (sh :: ShutdownStatus) (s :: SocketStatus) :: Bool where
CanShutdownSend 'Available 'Connected = 'True
CanShutdownSend 'CannotSend 'Connected = 'True
CanShutdownSend 'Available 'Listening = 'True
CanShutdownSend 'CannotSend 'Listening = 'True
type family CanShutdownBoth (sh :: ShutdownStatus) (s :: SocketStatus) :: Bool where
CanShutdownBoth 'Available 'Connected = 'True
CanShutdownBoth 'Available 'Listening = 'True
It turns out that trying to shut down sockets that aren’t in at least a connected or listening state will cause an exception. We’ve prevented that now.
The last function is the one that performs the actual state transition:
type family Shutdown (sh :: ShutdownStatus) (cmd :: NS.ShutdownCmd)
:: ShutdownStatus where
Shutdown 'Available 'NS.ShutdownSend = 'CannotSend
Shutdown 'Available 'NS.ShutdownReceive = 'CannotReceive
Shutdown 'Available 'NS.ShutdownBoth = 'CannotSendOrReceive
Shutdown 'CannotSend 'NS.ShutdownReceive = 'CannotSendOrReceive
Shutdown 'CannotReceive 'NS.ShutdownSend = 'CannotSendOrReceive
Unapologetically, we leverage the types of the shutdown commands from the underlying network library to complete this type function, and also take the time to prevent all inefficient or invalid states.
That’s the core of things! Next up, we’ll see how this affects the design of sending and receiving data over sockets.
The goal here is to only send or receive when it makes sense. In particular:
Here’s the API to achieve that:
send :: CanSend sh ~ 'True
=> ByteString -> SSocket f 'Tcp 'Connected sh -> IO Int
recv :: CanReceive sh ~ 'True
=> Int -> SSocket f 'Tcp 'Connected sh -> IO ByteString
sendTo :: CanSend sh ~ 'True
=> ByteString -> SockAddr f -> SSocket f 'Udp 'Unconnected sh -> IO Int
recvFrom :: (CanReceive sh ~ 'True, SockFam f)
=> Int -> SSocket f 'Udp 'Unconnected sh -> IO (ByteString, SockAddr f)
CanSend
and CanReceive
are simple enough, and currently depend only on the socket shutdown status:
type family CanSend (sh :: ShutdownStatus) :: Bool where
CanSend 'Available = 'True
CanSend 'CannotReceive = 'True
type family CanReceive (sh :: ShutdownStatus) :: Bool where
CanReceive 'Available = 'True
CanReceive 'CannotSend = 'True
The rest of the constraints are encoded in the type signature itself. Both the required protocol and the required socket state are specified, and only by passing in a socket with the appropriate state will a program compile.
Type inference saves us all and makes this possible.
As it’s given, this API is slightly over-preventative. Namely, it’s valid to use recvFrom/sendTo with a TCP socket, as long as it’s disconnected. Furthermore, it’s also fine to use send/recv with a UDP socket, also as long as it’s connected. The remaining cases throw errors. The nuanced version of this state machine gives us little gain, as it’s reasonable to expect to use TCP sockets mainly when connected and UDP sockets mainly when disconnected.
I want to highlight one last thing before moving on to the next section. Take a look at recvFrom
and sendTo
and their SockAddr
argument:
sendTo :: CanSend sh ~ 'True
=> ByteString -> SockAddr f -> SSocket f 'Udp 'Unconnected sh -> IO Int
recvFrom :: (CanReceive sh ~ 'True, SockFam f)
=> Int -> SSocket f 'Udp 'Unconnected sh -> IO (ByteString, SockAddr f)
This is a different structure than the one given by the underlying library with a touch of added safety.
The underlying library uses the following socket address data structure:
data SockAddr
= SockAddrInet PortNumber HostAddress
| SockAddrInet6 PortNumber FlowInfo HostAddress6 ScopeID
| SockAddrUnix String
...
The problem here, is that there’s nothing preventing us from using a SockAddrInet
with say, a Unix socket. Which, would throw an error. Or slightly more likely, an Inet6 structure with an Inet4 socket.
To address this, the fix is simple enough - we use a GADT version!
data SockAddr (f :: SocketFamily) where
SockAddrInet :: NS.PortNumber -> NS.HostAddress
-> SockAddr 'InetV4
SockAddrInet6 :: NS.PortNumber -> NS.FlowInfo -> NS.HostAddress6 -> !NS.ScopeID
-> SockAddr 'InetV6
SockAddrUnix :: ![Char]
-> SockAddr 'Unix
The most important bit here is the SocketFamily phantom type and how it affects the return type at each branch of this GADT. We reuse the SocketFamily type from earlier so we can relate socket instances to socket address structures.
Now if we look at a function like bind
:
It becomes impossible to pass in a socket address structure that isn’t of the same socket family as the socket being used. With that, our goal is achieved… mostly!
There’s actually a hack I’d like to talk about here. Let’s look at recvFrom
, including its implementation:
recvFrom :: (CanReceive sh ~ 'True, SockFam f)
=> Int -> SSocket f 'Udp 'Unconnected sh -> IO (ByteString, SockAddr f)
recvFrom n (SSocket s) = do
(bs, sa) <- NSB.recvFrom s n
return (bs, fromNetworkSockAddr sa)
In every function except this one, the type parameter f
occurs in the argument types. This made our lives easy - we could depend on the user to pass it what we needed without needing to communicate between the type and value levels.
However, here, recvFrom
returns a SockAddr f
. Worse yet, the underlying NSB.recvFrom
returns the original NS.SockAddr
structure, with no type information!
So we have to relate the two somehow, and convince the type-checker that it’s okay. This wasn’t easy, and I ended up with the following “proof”:
fromNetworkSockAddr :: forall f. SockFam f => NS.SockAddr -> SockAddr f
fromNetworkSockAddr netAddr = case (netAddr, socketFamily1 :: SocketFamily1 f) of
(NS.SockAddrInet p addr, SInetV4) -> SockAddrInet p addr
(NS.SockAddrInet6 p f addr scope, SInetV6) -> SockAddrInet6 p f addr scope
(NS.SockAddrUnix s, SUnix) -> SockAddrUnix s
_ -> error "impossible"
I’ll explain. When doing a pattern match, Haskell’s type-checker is able to refine what it knows. From the left side of the pattern match arrow (including any type-level data), it’s able to learn a lot about what should be on the right. So, that’s the why of needing to generate a SocketFamily1
object which carries the type-level data to associate the two - it convinces the type checker this all makes sense and we’re not just pulling a SockAddrInet6 'Inet6
out of thin air.
However, we aren’t able to convince the type-checker these are all the valid cases. It insists that we haven’t covered, say, NS.SockAddrUnix, InetV4
, which we know make no sense, and the type-checker would even reject!
So that’s that. A lil hack.
With that out of the way, let’s talk about making this API nicer to use.
Let’s take a look at the socket
function:
We’d use it look this to create a TCP4 client:
Which is … actually really tedious. As a user, we have to remember to type all that out in the correct order, or get scolded by the compiler. It’s really cool that we don’t have to pass in any arguments to get the sort of socket we want, but it came at quite the cost in usability.
We can do better - a lot better. I introduce several helper functions below:
tcp4Socket :: IO (SSocket 'InetV4 'Tcp 'Unconnected 'Available)
tcp4Socket = socket
tcp6Socket :: IO (SSocket 'InetV6 'Tcp 'Unconnected 'Available)
tcp6Socket = socket
tcpUnixSocket :: IO (SSocket 'Unix 'Tcp 'Unconnected 'Available)
tcpUnixSocket = socket
udp4Socket :: IO (SSocket 'InetV4 'Udp 'Unconnected 'Available)
udp4Socket = socket
udp6Socket :: IO (SSocket 'InetV6 'Udp 'Unconnected 'Available)
udp6Socket = socket
udpUnixSocket :: IO (SSocket 'Unix 'Udp 'Unconnected 'Available)
udpUnixSocket = socket
Their implementations are the easiest - type-inference handles 99% of the work. We just need to be careful about the type signatures. And like magic, our client socket code now becomes much nicer:
Another usability pattern, is that we’d like functions to automatically create our sockets and also close them, even if errors come up. There’s a very powerful function hiding in Haskell’s Control.Exception
standard prelude module: bracket!
bracket ::
IO a -- open/acquire a resource
-> (a -> IO b) -- close/release the resource
-> (a -> IO c) -- do some work with the resource
IO c -- return some value
Using bracket
, we can write some pretty simple helpers:
-- the core bracket helper, written point-free style
withSocket ::
(SockFam f, SockProto p)
=> (SSocket f p 'Unconnected 'Available -> IO a) -> IO a
withSocket = bracket socket close
-- expanding the point-free version:
-- withSocket f = bracket socket close f
-- one of six helper functions, also point-free style
withTcp4Socket :: (SSocket 'InetV4 'Tcp 'Unconnected 'Available -> IO a) -> IO a
withTcp4Socket = withSocket
The way the with
helpers work is like this:
-- serverAddress defined as earlier
runRudeServer :: SSocket 'InetV4 'Tcp 'Unconnected 'Available -> IO ()
runRudeServer s = forever $ do
server <- bind serverAddress s >>= listen 1
(client, _) <- accept server
_ <- send "bye" client
close client
-- guarantees that if the server ever crashes,
-- the socket will be closed
main = withTcp4Socket runRudeServer
In the next two sections, we’ll write a simple echo client and server using this library, and then test it all!
First, the client:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
module Main where
import Network.Typed.Socket
meowClient :: SSocket 'InetV4 'Tcp 'Unconnected 'Available -> IO ()
meowClient s = do
client <- connect serverAddress s
send "meow" client
whatTheOtherCatSaid <- recv 32 client
print whatTheOtherCatSaid
main :: IO ()
main = withTcp4Socket meowClient
Now the server:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
module Main where
import Network.Typed.Socket
meowServer :: SSocket 'InetV4 'Tcp 'Unconnected 'Available -> IO ()
meowServer s = do
server <- bind serverAddress s >>= listen 1
(client, _) <- accept server
print "a cat arrives!"
whatTheNiceCatSaid <- recv 32 client
send whatTheNiceCatSaid
main :: IO ()
main = withTcp4Socket meowServer
And that’s network programming in Haskell!
To go one step further, I’ll show how such a library could be tested. I used the echo server example above as a test case to make sure I didn’t break anything while doing the typed layer.
I’ll make use of the nice hspec
library.
Conceptually, the test seems like it should be this simple:
describe "Network.Typed.Socket" $ do
it "can echo server (TCP)" $ do
withTcp4Socket $ \server -> do
withTcp4Socket $ \client -> do
runServer server
runClient client
and as long as both finish, all’s well? Right?
Herein we encounter all the loveliness and nuance of network programming. There’s immediately three things wrong with this test suite:
runServer
, we’ll block on accept
. The client will never connect. The suite never ends. Eternal uncertainty!!runClient
, we don’t actually check things are being echoed. Let’s fix that.TIME_WAIT
mechanism where the operating system holds on to sockets after closure to ensure clean termination.Wow. Yeah. Welcome to network programming!
I’ll skip ahead to the solutions, and just say that a bit of concurrency and setting a socket option goes most of the way:
serverAddr :: SockAddr 'InetV4
serverAddr = SockAddrInet 2291 (NS.tupleToHostAddress (127,0,0,1))
runServer :: SSocket 'InetV4 'Tcp 'Unconnected 'Available -> MVar () -> IO ()
runServer s serverWaitLock =
makeAddrReusable s
>>= bind serverAddr
>>= listen 1
>>= (\server -> putMVar serverWaitLock () >> serverTest server)
runClient :: SSocket 'InetV4 'Tcp 'Unconnected 'Available -> IO ()
runClient s = do
client <- connect serverAddr s
n <- send "fish" client
bs <- recv 32 client
n `shouldBe` 4
bs `shouldBe` "fish"
main :: IO ()
main = hspec $ do
describe "Network.Typed.Socket" $ do
it "can echo server (TCP)" $ do
withTcp4Socket $ \server -> do
withTcp4Socket $ \client -> do
serverWaitLock <- newEmptyMVar
void (forkIO (runServer server serverWaitLock))
void (takeMVar serverWaitLock)
runClient client
That’s the whole test! The main changes needed were:
shouldBe
in the clientThis brings us close to the end. Our last bit of adventure involves thinking about socket options. Turns out, there’s a lot of them!
data SocketOption
= Debug
| ReuseAddr
| SType
| SoError
| DontRoute
| Broadcast
| SendBuffer
| RecvBuffer
| KeepAlive
| OOBInline
| TimeToLive
| MaxSegment
| NoDelay
| Cork
| Linger
| ReusePort
| RecvLowWater
| SendLowWater
| RecvTimeout
| SendTimeout
| UseLoopBack
| UserTimeout
| IPv6Only
Here’s the typed socket function for setting options as of right now:
As you can see, we’re not doing any of the property tracking in the types we did with socket status, protocol, and family. We’re just, running the effects.
The problem here is more complicated, because we want to track all the options we’ve enabled so far, which would be a type-level list.
Something like:
setSocketOption :: ValidToSetOption opts ~ 'True
=> NS.SocketOption -> Int -> SSocket f p s sh opts
-> IO (SSocket f p s sh (SetOpt NS.SocketOption opts))
This is not a completely terrible endeavor just yet. It becomes harder if we’d like to also track the values we set options to, and to possibly reconsider how this interacts with the rest of our current API.
Some options are boolean-valued (on, or off). Some take integers. Some take time-structures. It’s an adventure for another time!
We catch a wide class of errors with this new API design. However, there’s still one major weakness to this approach because of the limitations of the type system we’re working in.
What happens if we throw some threading into the mix??
g server = do
forkIO (runServer server)
close server -- oh no
runServer server = forever $ do
-- do cool server stuff, I hope
We were able to close the server socket in the main thread, and as a result, the type-state will be inconsistent in the child thread. The server will actually be closed but will have a type like SSocket f p 'Listening sh
.
Welp.
This is a limitation of the type-system, and there’s naught we can to help the compiler help us.
Fortunately, extensions to the Haskell type-system are being worked on that will allow it to encode these sorts of constraints. Constraints that make it possible to say, “I’ve handed this resource off to another thread. Please make no further modifications on this thread!”. That’s linear types - things can be “consumed”.
Here’s the haskell paper detailing how this system would work.
And that’s that! Phantom types, singletons, to the type-level and back to the value-level again.
If you’re interested in seeing the full implementation, you can check it out on gitlab.
I’m looking for new work, so send me an email if you’d like to work with me on making reliable systems for all.
Thanks for reading!