packages feed

hedis-0.16.0: src/Database/Redis/ConnectionContext.hs

{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module Database.Redis.ConnectionContext (
    ConnectionContext(..)
  , ConnectTimeout(..)
  , ConnectionLostException(..)
  , ConnectAddr(..)
  , connect
  , disconnect
  , send
  , recv
  , errConnClosed
  , enableTLS
  , flush
  , ioErrorToConnLost
) where

import Control.Monad(when)
import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as LB
import qualified Data.IORef as IOR
import Control.Concurrent.MVar(newMVar, readMVar, swapMVar)
import Control.Exception(bracketOnError, Exception, throwIO, try, finally, mask_)
import Data.Functor(void)
import qualified Network.Socket as NS
import qualified Network.TLS as TLS
import System.IO(Handle, hSetBinaryMode, hClose, IOMode(..), hFlush, hIsOpen)
import System.IO.Error(catchIOError)
import System.Timeout (timeout)

data ConnectionContext = NormalHandle Handle | TLSContext TLS.Context Handle

instance Show ConnectionContext where
    show (NormalHandle _) = "NormalHandle"
    show (TLSContext _ _) = "TLSContext"

data Connection = Connection
    { ctx :: ConnectionContext
    , lastRecvRef :: IOR.IORef (Maybe B.ByteString) }

instance Show Connection where
    show Connection{..} = "Connection{ ctx = " ++ show ctx ++ ", lastRecvRef = IORef}"

data ConnectPhase
  = PhaseUnknown
  | PhaseResolve
  | PhaseOpenSocket
  deriving (Show)

newtype ConnectTimeout = ConnectTimeout ConnectPhase
  deriving (Show)

instance Exception ConnectTimeout

data ConnectionLostException = ConnectionLost deriving Show
instance Exception ConnectionLostException

data ConnectAddr
  = ConnectAddrHostPort NS.HostName NS.PortNumber
  | ConnectAddrUnixSocket String
  deriving (Eq, Show)

connect :: ConnectAddr -> Maybe Int -> Maybe TLS.ClientParams -> IO ConnectionContext
connect connectAddr timeoutOpt mTlsParams =
  bracketOnError hConnect hClose $ \h -> do
    hSetBinaryMode h True
    case (mTlsParams, connectAddr) of
      (Just defaultTlsParams, ConnectAddrHostPort host port) -> do
        -- The defaultTlsParams are used to connect to the first
        -- host in the cluster, other hosts have different
        -- hostnames and so require a different server
        -- identification params
        let tlsParams = defaultTlsParams {
              TLS.clientServerIdentification =  (host, Char8.pack $ show port)
            }
        enableTLS tlsParams (NormalHandle h)
      _ -> return $ NormalHandle h
  where
        hConnect = do
          phaseMVar <- newMVar PhaseUnknown
          let doConnect = hConnect' phaseMVar
          case timeoutOpt of
            Nothing -> doConnect
            Just micros -> do
              result <- timeout micros doConnect
              case result of
                Just h -> return h
                Nothing -> do
                  phase <- readMVar phaseMVar
                  errConnectTimeout phase
        hConnect' mvar = bracketOnError createSock NS.close $ \sock -> do
          NS.setSocketOption sock NS.KeepAlive 1
          void $ swapMVar mvar PhaseResolve
          void $ swapMVar mvar PhaseOpenSocket
          NS.socketToHandle sock ReadWriteMode
          where
            createSock = case connectAddr of
              ConnectAddrHostPort hostName portNumber -> do
                addrInfo <- getHostAddrInfo hostName portNumber
                connectSocket addrInfo
              ConnectAddrUnixSocket addr -> bracketOnError
                (NS.socket NS.AF_UNIX NS.Stream NS.defaultProtocol)
                NS.close
                (\sock -> NS.connect sock (NS.SockAddrUnix addr) >> return sock)

getHostAddrInfo :: NS.HostName -> NS.PortNumber -> IO [NS.AddrInfo]
getHostAddrInfo hostname port = NS.getAddrInfo (Just hints) (Just hostname) (Just $ show port)
  where
    hints = NS.defaultHints
      { NS.addrSocketType = NS.Stream }

errConnectTimeout :: ConnectPhase -> IO a
errConnectTimeout phase = throwIO $ ConnectTimeout phase

connectSocket :: [NS.AddrInfo] -> IO NS.Socket
connectSocket [] = error "connectSocket: unexpected empty list"
connectSocket (addr:rest) = tryConnect >>= \case
  Right sock -> return sock
  Left err   -> if null rest
                then throwIO err
                else connectSocket rest
  where
    tryConnect :: IO (Either IOError NS.Socket)
    tryConnect = bracketOnError createSock NS.close $ \sock ->
      try (NS.connect sock $ NS.addrAddress addr) >>= \case
      Right () -> return (Right sock)
      Left err -> NS.close sock >> return (Left err)
      where
        createSock = NS.socket (NS.addrFamily addr)
                               (NS.addrSocketType addr)
                               (NS.addrProtocol addr)

send :: ConnectionContext -> B.ByteString -> IO ()
send (NormalHandle h) requestData =
    ioErrorToConnLost (B.hPut h requestData)
send (TLSContext ctx _) requestData =
    ioErrorToConnLost (TLS.sendData ctx (LB.fromStrict requestData))

recv :: ConnectionContext -> IO B.ByteString
recv (NormalHandle h) = ioErrorToConnLost $ B.hGetSome h 4096
recv (TLSContext ctx _) = TLS.recvData ctx


ioErrorToConnLost :: IO a -> IO a
ioErrorToConnLost a = a `catchIOError` const errConnClosed

errConnClosed :: IO a
errConnClosed = throwIO ConnectionLost


enableTLS :: TLS.ClientParams -> ConnectionContext -> IO ConnectionContext
enableTLS tlsParams (NormalHandle h) = do
  ctx <- TLS.contextNew h tlsParams
  TLS.handshake ctx
  return $! TLSContext ctx h
enableTLS _ c@(TLSContext _ _) = return c


disconnect :: ConnectionContext -> IO ()
disconnect (NormalHandle h) = mask_ $ do
  open <- hIsOpen h
  when open $ hClose h
disconnect (TLSContext ctx h) =
  TLS.bye ctx `finally` TLS.contextClose ctx `finally` (hIsOpen h >>= \open -> when open $ hClose h)

flush :: ConnectionContext -> IO ()
flush (NormalHandle h) = hFlush h
flush (TLSContext ctx _) = TLS.contextFlush ctx