Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What's an idiomatic way of handling a lazy input channel in Haskell

I am implementing an IRC bot and since I am connecting over SSL by using OpenSSL.Session I use lazyRead function to read data from the socket. During the initial phase of the connection I need to perform several things in order: nick negotiation, nickserv identification, joining channels etc) so there is some state involved. Right now I came up with the following:

data ConnectionState = Initial | NickIdentification | Connected

listen :: SSL.SSL -> IO ()
listen ssl = do
  lines <- BL.lines `fmap` SSL.lazyRead ssl
  evalStateT (mapM_ (processLine ssl) lines) Initial

processLine :: SSL.SSL -> BL.ByteString -> StateT ConnectionState IO ()
processLine ssl line = do case message of
                            Just a -> processMessage ssl a
                            Nothing -> return ()
  where message = IRC.decode $ BL.toStrict line

processMessage :: SSL.SSL -> IRC.Message -> StateT ConnectionState IO ()
processMessage ssl m = do
    state <- S.get
    case state of
      Initial -> when (IRC.msg_command m == "376") $ do
        liftIO $ putStrLn "connected!"
        liftIO $ privmsg ssl "NickServ" ("identify " ++ nick_password)
        S.put NickIdentification
      NickIdentification -> do
        when (identified m) $ do
          liftIO $ putStrLn "identified!"
          liftIO $ joinChannel ssl chan
          S.put Connected
      Connected -> return ()
    liftIO $ print m
    when (IRC.msg_command m == "PING") $ (liftIO . pong . mconcat . map show) (IRC.msg_params m)

So when I get to the "Connected" state I still end up going through the case statement even though it's only really needed to initialize the connection. The other problem is that adding nested StateT's would be very painful.

Other way would be to replace mapM with something custom to only process lines until we are connected and then start another loop over the rest. This would require either keeping track of what's left in the list or invoking SSL.lazyRead once again (which is not too bad).

Another solution is to keep the remaining lines list in the state and draw lines when needed similar to getLine.

What's the better thing to do in this case? Would Haskell's laziness make it so that we go directly to Connected case after state stops updating or is case always strict?

like image 353
egdmitry Avatar asked Mar 19 '15 06:03

egdmitry


1 Answers

You can use the Pipe type from pipes. The trick is that instead of creating a state machine and a transition function you can encode the the state implicitly in the control flow of the Pipe.

Here is what the Pipe would look like:

stateful :: Pipe ByteString ByteString IO r
stateful = do
    msg <- await
    if (IRC.msg_command msg == "376")
        then do
            liftIO $ putStrLn "connected!"
            liftIO $ privmsg ssl "NickServ" ("identify " ++ nick_password)
            yield msg
            nick
        else stateful

nick :: Pipe ByteString ByteString IO r
nick = do
    msg <- await
    if identified msg
        then do
            liftIO $ putStrLn "identified!"
            liftIO $ joinChannel ssl chan
            yield msg
            cat  -- Forward the remaining input to output indefinitely 
        else nick

The stateful pipe corresponds to the stateful part of your processMessage function. It handles initialization and authentication, but defers further message processing to downstream stages by re-yielding the msg.

You can then loop over every message this Pipe yields by using for:

processMessage :: Consumer ByteString IO r
processMessage = for stateful $ \msg -> do
    liftIO $ print m
    when (IRC.msg_command m == "PING") $ (liftIO . pong . mconcat . map show) (IRC.msg_params m)

Now all you need is a source of ByteString lines to feed to processMessage. You can use the following Producer:

lines :: Producer ByteString IO ()
lines = do
    bs <- liftIO (ByteString.getLine)
    if ByteString.null bs
        then return ()
        else do
            yield bs
            lines

Then you can connect lines to processMessage and run them:

runEffect (lines >-> processMessage) :: IO ()

Note that the lines Producer does not use lazy IO. It will work even if you use the strict ByteString module, but the behavior of the entire program will still be lazy.

If you want to learn more about how pipes works, you can read the pipes tutorial.

like image 59
Gabriella Gonzalez Avatar answered Sep 20 '22 03:09

Gabriella Gonzalez