1414import abc
1515import asyncio
1616import base64
17+ import functools
1718import hashlib
19+ import logging
1820import os
1921import sys
2022import struct
2628from tornado .concurrent import Future , future_set_result_unless_cancelled
2729from tornado .escape import utf8 , native_str , to_unicode
2830from tornado import gen , httpclient , httputil
29- from tornado .ioloop import IOLoop , PeriodicCallback
31+ from tornado .ioloop import IOLoop
3032from tornado .iostream import StreamClosedError , IOStream
3133from tornado .log import gen_log , app_log
3234from tornado .netutil import Resolver
@@ -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,41 @@ 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+ """The interval for sending websocket pings.
283+
284+ If this is non-zero, the websocket will send a ping every
285+ ping_interval seconds.
286+ The client will respond with a "pong". The connection can be configured
287+ to timeout on late pong delivery using ``websocket_ping_timeout``.
278288
279- Set websocket_ping_interval = 0 to disable pings.
289+ Set ``websocket_ping_interval = 0`` to disable pings.
290+
291+ Default: ``0``
280292 """
281293 return self .settings .get ("websocket_ping_interval" , None )
282294
283295 @property
284296 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.
297+ """Timeout if no pong is received in this many seconds.
298+
299+ To be used in combination with ``websocket_ping_interval > 0``.
300+ If a ping response (a "pong") is not received within
301+ ``websocket_ping_timeout`` seconds, then the websocket connection
302+ will be closed.
303+
304+ This can help to clean up clients which have disconnected without
305+ cleanly closing the websocket connection.
306+
307+ Note, the ping timeout cannot be longer than the ping interval.
308+
309+ Set ``websocket_ping_timeout = 0`` to disable the ping timeout.
310+
311+ Default: ``min(ping_interval, 30)``
312+
313+ .. versionchanged:: 6.5.0
314+ Default changed from the max of 3 pings or 30 seconds.
315+ The ping timeout can no longer be configured longer than the
316+ ping interval.
288317 """
289318 return self .settings .get ("websocket_ping_timeout" , None )
290319
@@ -831,11 +860,10 @@ def __init__(
831860 # the effect of compression, frame overhead, and control frames.
832861 self ._wire_bytes_in = 0
833862 self ._wire_bytes_out = 0
834- self .ping_callback = None # type: Optional[PeriodicCallback]
835- self .last_ping = 0.0
836- self .last_pong = 0.0
863+ self ._received_pong = False # type: bool
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
@@ -1232,7 +1260,7 @@ def _handle_message(self, opcode: int, data: bytes) -> "Optional[Future[None]]":
12321260 self ._run_callback (self .handler .on_ping , data )
12331261 elif opcode == 0xA :
12341262 # Pong
1235- self .last_pong = IOLoop . current (). time ()
1263+ self ._received_pong = True
12361264 return self ._run_callback (self .handler .on_pong , data )
12371265 else :
12381266 self ._abort ()
@@ -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,69 @@ 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 self .ping_interval
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 (
1340+ # prevent multiple ping coroutines being run in parallel
1341+ not self ._ping_coroutine
1342+ # only run the ping coroutine if a ping interval is configured
1343+ and self .ping_interval > 0
1344+ ):
1345+ self ._ping_coroutine = asyncio . create_task ( self . periodic_ping () )
13061346
1307- def periodic_ping (self ) -> None :
1308- """Send a ping to keep the websocket alive
1347+ async def periodic_ping (self ) -> None :
1348+ """Send a ping and wait for a pong if ping_timeout is configured.
13091349
13101350 Called periodically if the websocket_ping_interval is set and non-zero.
13111351 """
1312- if self .is_closing () and self .ping_callback is not None :
1313- self .ping_callback .stop ()
1314- return
1352+ interval = self .ping_interval
1353+ timeout = self .ping_timeout
13151354
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 ()
1329- return
1355+ await asyncio .sleep (interval )
13301356
1331- self .write_ping (b"" )
1332- self .last_ping = now
1357+ while True :
1358+ # send a ping
1359+ self ._received_pong = False
1360+ ping_time = IOLoop .current ().time ()
1361+ self .write_ping (b"" )
13331362
1334- def set_nodelay (self , x : bool ) -> None :
1335- self .stream .set_nodelay (x )
1363+ # wait until the ping timeout
1364+ await asyncio .sleep (timeout )
1365+
1366+ # make sure we received a pong within the timeout
1367+ if timeout > 0 and not self ._received_pong :
1368+ self .close (reason = "ping timed out" )
1369+ return
1370+
1371+ # wait until the next scheduled ping
1372+ await asyncio .sleep (IOLoop .current ().time () - ping_time + interval )
13361373
13371374
13381375class WebSocketClientConnection (simple_httpclient ._HTTPConnection ):
0 commit comments