-
Notifications
You must be signed in to change notification settings - Fork 116
Description
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 1000000Even 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 ...
-- ^^^^^^
-- exists3. 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.