Skip to content

Feedback suggestions on withPingPong API #246

@ulidtko

Description

@ulidtko

Hi @jaspervdj! Huge thanks for maintaining this package.

I've got a Servant webserver with a Websocket endpoint built on top of this library.

Having noticed the deprecation on forkPingThread, I gave withPingPong a try.

Observed a few issues regarding API ergonomics — sharing, if that helps.

1. Generalize to MonadIO, please

First and most serious ask, is to generalize the signature:

withPingPong :: PingPongOptions -> Connection -> (Connection -> IO ()) -> IO () 
-- ↓↓↓
withPingPong :: MonadIO m
             => PingPongOptions -> Connection -> (Connection -> m ()) -> m () 

To motivate, let me sketch an example of a typical Servant server that includes a WS endpoint:

type MyApi = "example.post204" :> ReqBody '[OctetStream] LByteString :> PostNoContent
        :<|>  -- many other endpoints
        :<|> "evstream" :> WebSocket

server :: Server MyApi
server = handlerSample
    :<|> -- other handlers
    :<|> handleEventStream 

handlerSample :: LByteString -> Handler NoContent
handlerSample _reqbody = return NoContent

handleEventStream :: Network.Websockets.Connection -> Handler ()
handleEventStream c = do
  liftIO $ forkPingThread c 10
  liftIO . forM_ [1..] $ \i -> do
    sendTextData c (pack $ show (i :: Int)) >> threadDelay 1000000

Even without going into custom monad stacks (which is fairly common to do, too) — notice that, in the vanilla servant textbook setup, endpoint handlers like handlerSample run in the Handler monad, not IO directly — including Websocket endpoints.

The Handler is of course MonadIO (just like a custom stack with logging, config Reader, metrics, etc, would be) — so it's trivial to liftIO simple IO () actions like forkPingThread into it.

It stops being trivial when the IO appears in both contravariant & covariant positions — like in withPingPong signature.

That can be worked around, nontrivially. By employing unliftio, I can write an orphan instance that reaches into Handler guts:

instance MonadUnliftIO Servant.Handler where
  withRunInIO :: ((forall x. Handler x -> IO x) -> IO y) -> Handler y
  withRunInIO wrapped = Handler . ExceptT $ do
    let runner = runHandler >=> either throwIO return
    (Right <$> wrapped runner) `catch` (return . Left @ServerError)

Then, I can wrap withPingPong to run my handler in whatever monad I need, be it Handler or a custom transformer stack WebM, as long as it's MonadUnliftIO:

websocketCmdChan :: WS.Connection -> WebM ()
websocketCmdChan wsconn
  = withWebsocketKeepAlive wsconn
  $ forever
  $ do
    -- WS.receiveData wsconn
    -- ...
    -- WS.sendTextData wsconn ...
    -- ...
  where
    withWebsocketKeepAlive :: MonadUnliftIO m => WS.Connection -> m () -> m ()
    withWebsocketKeepAlive conn action
      = askRunInIO >>= \runInIO -> liftIO $
        WS.withPingPong WS.defaultPingPongOptions conn (const $ runInIO action)

... which is quite a hassle just to use a library function to get websocket keepalives, right?

The whole workaround can be abolished, the restriction to MonadUnliftIO can be avoided (relaxed to only MonadIO), the orphan instance & the extra dependency can be dropped, and the library function can be made to Just Work™ in less-trivial cases — by generalizing its signature to MonadIO like suggested above.

2. The Connection argument is awkward

The next suggestion is minor — its respective workaround is a const call — to change the signature of withPingPong like so:

withPingPong :: PingPongOptions -> Connection -> (Connection -> IO ()) -> IO () 
-- ↓↓↓
withPingPong :: PingPongOptions -> Connection -> IO () -> IO ()

The signature as it stands, suggests CPS style, misleadingly, because the wrapped action is not actually used as a continuation.

I also don't see how, logically, the downstream Connection (that the wrapped action accepts) could be different from the Connection passed to withPingPong. Indeed, the current implementation just passes it straight through. Is there an idea that these 2 may somehow end up being different?..

The lexical scope that invokes withPingPong must have the connection (otherwise the function can't be called); and conceivably, the wrapped action can often be an inline do-block — having access to the same connection in the same scope.

I mean, here, there's no point to demand \wsconn' ->, as we still have wsconn in scope:

websocketCmdChan :: WS.Connection -> WebM ()
websocketCmdChan wsconn
  = withWebsocketKeepAlive wsconn
  $ forever
  $ do
    -- WS.receiveData wsconn
    -- ...
    -- WS.sendTextData wsconn ...
    --                 ^^^^^^
    --                 exists

3. Ditto for withPingThread

As you probably know well enough already, not all WS clients react to Ping, as they must per the standard. Turns out, I'm currently working with one of those. Regardless of not receiving Pong's, I still want to send periodic Ping's to keep-alive the socket (as it gets proxied). AFAICS, withPingThread is provided for that exactly.

It has the same MonadIO issue as withPingPong however, so that suggestion still applies.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions