1414import abc
1515import asyncio
1616import base64
17+ import functools
1718import hashlib
19+ import logging
1820import os
1921import sys
2022import struct
@@ -97,6 +99,9 @@ def log_exception(
9799
98100_default_max_message_size = 10 * 1024 * 1024
99101
102+ # log to "gen_log" but suppress duplicate log messages
103+ de_dupe_gen_log = functools .lru_cache (gen_log .log )
104+
100105
101106class WebSocketError (Exception ):
102107 pass
@@ -274,17 +279,40 @@ async def get(self, *args: Any, **kwargs: Any) -> None:
274279
275280 @property
276281 def ping_interval (self ) -> Optional [float ]:
277- """The interval for websocket keep-alive pings.
282+ """Send periodic pings down the websocket.
283+
284+ This will send a ping every ``wesocket_ping_interval`` seconds.
285+ The client will respond with a "pong". The connection can be configured
286+ to timeout on late pong delivery using ``websocket_ping_timeout``.
278287
279- Set websocket_ping_interval = 0 to disable pings.
288+ Set ``websocket_ping_interval = 0`` to disable pings.
289+
290+ Default: ``0``
280291 """
281292 return self .settings .get ("websocket_ping_interval" , None )
282293
283294 @property
284295 def ping_timeout (self ) -> Optional [float ]:
285- """If no ping is received in this many seconds,
286- close the websocket connection (VPNs, etc. can fail to cleanly close ws connections).
287- Default is max of 3 pings or 30 seconds.
296+ """Timeout if no pong is received in this many seconds.
297+
298+ To be used in combination with ``websocket_ping_interval > 0``.
299+ If a ping response (a "pong") is not received within
300+ ``websocket_ping_timeout`` seconds, then the websocket connection
301+ will be closed.
302+
303+ This can help to clean up clients which have disconnected without
304+ cleanly closing the websocket connection.
305+
306+ Note, the ping timeout cannot be longer than the ping interval.
307+
308+ Set ``websocket_ping_timeout = 0`` to disable the ping timeout.
309+
310+ Default: ``min(ping_interval, 30)``
311+
312+ .. versionchanged:: 6.5.0
313+ Default changed from the max of 3 pings or 30 seconds.
314+ The ping timeout can no longer be configured longer than the
315+ ping interval.
288316 """
289317 return self .settings .get ("websocket_ping_timeout" , None )
290318
@@ -832,10 +860,10 @@ def __init__(
832860 self ._wire_bytes_in = 0
833861 self ._wire_bytes_out = 0
834862 self .ping_callback = None # type: Optional[PeriodicCallback]
835- self .last_ping = 0.0
836863 self .last_pong = 0.0
837864 self .close_code = None # type: Optional[int]
838865 self .close_reason = None # type: Optional[str]
866+ self ._ping_coroutine = None # type: Optional[asyncio.Task]
839867
840868 # Use a property for this to satisfy the abc.
841869 @property
@@ -1266,9 +1294,9 @@ def close(self, code: Optional[int] = None, reason: Optional[str] = None) -> Non
12661294 self ._waiting = self .stream .io_loop .add_timeout (
12671295 self .stream .io_loop .time () + 5 , self ._abort
12681296 )
1269- if self .ping_callback :
1270- self .ping_callback . stop ()
1271- self .ping_callback = None
1297+ if self ._ping_coroutine :
1298+ self ._ping_coroutine . cancel ()
1299+ self ._ping_coroutine = None
12721300
12731301 def is_closing (self ) -> bool :
12741302 """Return ``True`` if this connection is closing.
@@ -1279,60 +1307,76 @@ def is_closing(self) -> bool:
12791307 """
12801308 return self .stream .closed () or self .client_terminated or self .server_terminated
12811309
1310+ def set_nodelay (self , x : bool ) -> None :
1311+ self .stream .set_nodelay (x )
1312+
12821313 @property
1283- def ping_interval (self ) -> Optional [ float ] :
1314+ def ping_interval (self ) -> float :
12841315 interval = self .params .ping_interval
12851316 if interval is not None :
12861317 return interval
12871318 return 0
12881319
12891320 @property
1290- def ping_timeout (self ) -> Optional [ float ] :
1321+ def ping_timeout (self ) -> float :
12911322 timeout = self .params .ping_timeout
12921323 if timeout is not None :
1324+ if self .ping_interval and timeout > self .ping_interval :
1325+ de_dupe_gen_log (
1326+ # Note: using de_dupe_gen_log to prevent this message from
1327+ # being duplicated for each connection
1328+ logging .WARNING ,
1329+ f'The websocket_ping_timeout ({ timeout } ) cannot be longer'
1330+ f' than the websocket_ping_interval ({ self .ping_interval } ).'
1331+ f'\n Setting websocket_ping_timeout={ self .ping_interval } '
1332+ )
1333+ return self .ping_interval
12931334 return timeout
1294- assert self .ping_interval is not None
1295- return max (3 * self .ping_interval , 30 )
1335+ return max (self .ping_interval , 30 )
12961336
12971337 def start_pinging (self ) -> None :
12981338 """Start sending periodic pings to keep the connection alive"""
1299- assert self .ping_interval is not None
1300- if self .ping_interval > 0 :
1301- self .last_ping = self .last_pong = IOLoop .current ().time ()
1302- self .ping_callback = PeriodicCallback (
1303- self .periodic_ping , self .ping_interval * 1000
1304- )
1305- self .ping_callback .start ()
1339+ if not self ._ping_coroutine :
1340+ self ._ping_coroutine = asyncio .create_task (self .periodic_ping ())
13061341
1307- def periodic_ping (self ) -> None :
1308- """Send a ping to keep the websocket alive
1342+ async def periodic_ping (self ) -> None :
1343+ """Send a ping and wait for a pong if ping_timeout is configured.
13091344
13101345 Called periodically if the websocket_ping_interval is set and non-zero.
13111346 """
1312- if self .is_closing () and self .ping_callback is not None :
1313- self .ping_callback .stop ()
1314- return
1347+ interval = self .ping_interval
1348+ timeout = self .ping_timeout
13151349
1316- # Check for timeout on pong. Make sure that we really have
1317- # sent a recent ping in case the machine with both server and
1318- # client has been suspended since the last ping.
1319- now = IOLoop .current ().time ()
1320- since_last_pong = now - self .last_pong
1321- since_last_ping = now - self .last_ping
1322- assert self .ping_interval is not None
1323- assert self .ping_timeout is not None
1324- if (
1325- since_last_ping < 2 * self .ping_interval
1326- and since_last_pong > self .ping_timeout
1327- ):
1328- self .close ()
1350+ if interval <= 0 :
1351+ # no pings configured
13291352 return
13301353
1331- self .write_ping (b"" )
1332- self .last_ping = now
1354+ await asyncio .sleep (interval )
13331355
1334- def set_nodelay (self , x : bool ) -> None :
1335- self .stream .set_nodelay (x )
1356+ while True :
1357+ # send a ping
1358+ ping_time = IOLoop .current ().time ()
1359+ self .write_ping (b"" )
1360+
1361+ # wait until the ping timeout
1362+ await asyncio .sleep (timeout )
1363+
1364+ # make sure we received a pong within the timeout
1365+ if (
1366+ timeout > 0
1367+ # and ping_time - self.last_pong >= timeout
1368+ and (
1369+ # pong took longer than the timeout
1370+ self .last_pong - ping_time > timeout
1371+ # pong was not received
1372+ or self .last_pong - ping_time < 0
1373+ )
1374+ ):
1375+ self .close (reason = 'ping timed out' )
1376+ return
1377+
1378+ # wait until the next scheduled ping
1379+ await asyncio .sleep (interval - timeout )
13361380
13371381
13381382class WebSocketClientConnection (simple_httpclient ._HTTPConnection ):
0 commit comments