Skip to content

Commit 54af556

Browse files
committed
Implemented expected timeout handling in websockets
The 'withPingThread' function of the websockets package is not dealing with missing pong messages at all. This means that websocket connection might never be cleaned up when the connection is not closed correctly. This change implements a manual ping and pong handling that closes a connection after not receiving a pong message within 10 seconds after sending a ping to the client.
1 parent b7e1ccf commit 54af556

File tree

1 file changed

+59
-1
lines changed

1 file changed

+59
-1
lines changed

IHP/WebSocket.hs

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ import qualified Data.Aeson as Aeson
2929
import qualified IHP.Log.Types as Log
3030
import qualified IHP.Log as Log
3131

32+
import Control.Concurrent.Chan
33+
import Control.Concurrent
34+
import System.Timeout
35+
import Data.Function (fix)
36+
import qualified Network.WebSockets.Connection as WebSocket
37+
3238
class WSApp state where
3339
initialState :: state
3440

@@ -47,7 +53,15 @@ startWSApp connection = do
4753
let ?state = state
4854
let ?connection = connection
4955

50-
result <- Exception.try ((Websocket.withPingThread connection 30 (onPing @state) (run @state)) `Exception.finally` onClose @state)
56+
let runWithPongChan pongChan = do
57+
let connectionOnPong = writeChan pongChan ()
58+
let ?connection = connection
59+
{ WebSocket.connectionOptions = (get #connectionOptions connection) { WebSocket.connectionOnPong }
60+
}
61+
in
62+
run @state
63+
64+
result <- Exception.try ((withPinger connection runWithPongChan) `Exception.finally` onClose @state)
5165
case result of
5266
Left (e@Exception.SomeException{}) ->
5367
case Exception.fromException e of
@@ -90,3 +104,47 @@ instance Websocket.WebSocketsData UUID where
90104
fromDataMessage (Websocket.Binary byteString) = UUID.fromLazyASCIIBytes byteString |> Maybe.fromJust
91105
fromLazyByteString byteString = UUID.fromLazyASCIIBytes byteString |> Maybe.fromJust
92106
toLazyByteString = UUID.toLazyASCIIBytes
107+
108+
data PongTimeout
109+
= PongTimeout
110+
deriving (Show)
111+
112+
instance Exception PongTimeout
113+
114+
pingWaitTime :: Int
115+
pingWaitTime = 30
116+
117+
118+
-- | Pings the client every 30 seconds and expects a pong response within 10 secons. If no pong response
119+
-- is received within 10 seconds, it will kill the connection.
120+
--
121+
-- We cannot use the withPingThread of the websockets package as this doesn't deal with pong messages. So
122+
-- open connection will stay around forever.
123+
--
124+
-- This implementation is based on https://github.com/jaspervdj/websockets/issues/159#issuecomment-552776502
125+
withPinger conn action = do
126+
pongChan <- newChan
127+
mainAsync <- async $ action pongChan
128+
pingerAsync <- async $ runPinger conn pongChan
129+
130+
waitEitherCatch mainAsync pingerAsync >>= \case
131+
-- If the application async died for any reason, kill the pinger async
132+
Left result -> do
133+
cancel pingerAsync
134+
case result of
135+
Left exception -> throw exception
136+
Right result -> pure ()
137+
-- The pinger thread should never throw an exception. If it does, kill the app thread
138+
Right (Left exception) -> do
139+
cancel mainAsync
140+
throw exception
141+
-- The pinger thread exited due to a pong timeout. Tell the app thread about it.
142+
Right (Right ()) -> cancelWith mainAsync PongTimeout
143+
144+
runPinger conn pongChan = fix $ \loop -> do
145+
Websocket.sendPing conn (mempty :: ByteString)
146+
threadDelay pingWaitTime
147+
-- See if we got a pong in that time
148+
timeout 1000000 (readChan pongChan) >>= \case
149+
Just () -> loop
150+
Nothing -> return ()

0 commit comments

Comments
 (0)