diff --git a/docs/web.rst b/docs/web.rst index 956336bda..00066ccdf 100644 --- a/docs/web.rst +++ b/docs/web.rst @@ -224,14 +224,22 @@ of `UIModule` or UI methods to be made available to templates. May be set to a module, dictionary, or a list of modules and/or dicts. See :ref:`ui-modules` for more details. - * ``websocket_ping_interval``: If set to a number, all websockets will - be pinged every n seconds. This can help keep the connection alive - through certain proxy servers which close idle connections, and it - can detect if the websocket has failed without being properly closed. - * ``websocket_ping_timeout``: If the ping interval is set, and the - server doesn't receive a 'pong' in this many seconds, it will close - the websocket. The default is three times the ping interval, with a - minimum of 30 seconds. Ignored if the ping interval is not set. + * ``websocket_ping_interval``: If the ping interval has a non-zero + value, a ping will be sent periodically every + ``websocket_ping_interval`` seconds, and the connection will be + closed if a response is not received before the + ``websocket_ping_timeout``. + This can help keep the connection alive through certain proxy + servers which close idle connections, and it can detect if the + websocket has failed without being properly closed. + * ``websocket_ping_timeout``: For use with ``websocket_ping_interval``, + if the server does not receive a pong within this many seconds, it + will close the websocket_ping_timeout. + The default timeout is equal to the ping interval. The ping timeout + will be turned off if the ping interval is not set or if the + timeout is set to ``0``. + This can help to detect disconnected clients to avoid keeping + inactive connections open. Authentication and security settings: diff --git a/tornado/test/websocket_test.py b/tornado/test/websocket_test.py index 1f317fb0d..494c4bf67 100644 --- a/tornado/test/websocket_test.py +++ b/tornado/test/websocket_test.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import datetime import functools import socket import traceback @@ -861,16 +862,21 @@ def initialize(self, close_future=None, compression_options=None): return app @staticmethod - def suppress_pong(ws): - """Suppress the client's "pong" response.""" + def install_hook(ws): + """Optionally suppress the client's "pong" response.""" + + ws.drop_pongs = False + ws.pongs_received = 0 def wrapper(fcn): - def _inner(oppcode: int, data: bytes): - if oppcode == 0xA: # NOTE: 0x9=ping, 0xA=pong - # prevent pong responses - return + def _inner(opcode: int, data: bytes): + if opcode == 0xA: # NOTE: 0x9=ping, 0xA=pong + ws.pongs_received += 1 + if ws.drop_pongs: + # prevent pong responses + return # leave all other responses unchanged - return fcn(oppcode, data) + return fcn(opcode, data) return _inner @@ -883,13 +889,14 @@ def test_client_ping_timeout(self): ws = yield self.ws_connect( "/", ping_interval=interval, ping_timeout=interval / 4 ) + self.install_hook(ws) # websocket handler (server side) handler = self.handlers[0] for _ in range(5): # wait for the ping period - yield gen.sleep(0.2) + yield gen.sleep(interval) # connection should still be open from the server end self.assertIsNone(handler.close_code) @@ -898,8 +905,12 @@ def test_client_ping_timeout(self): # connection should still be open from the client end assert ws.protocol.close_code is None + # Check that our hook is intercepting messages; allow for + # some variance in timing (due to e.g. cpu load) + self.assertGreaterEqual(ws.pongs_received, 4) + # suppress the pong response message - self.suppress_pong(ws) + ws.drop_pongs = True # give the server time to register this yield gen.sleep(interval * 1.5) @@ -912,6 +923,23 @@ def test_client_ping_timeout(self): self.assertEqual(ws.protocol.close_code, 1000) +class PingCalculationTest(unittest.TestCase): + def test_ping_sleep_time(self): + from tornado.websocket import WebSocketProtocol13 + + now = datetime.datetime(2025, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + interval = 10 # seconds + last_ping_time = datetime.datetime( + 2025, 1, 1, 11, 59, 54, tzinfo=datetime.timezone.utc + ) + sleep_time = WebSocketProtocol13.ping_sleep_time( + last_ping_time=last_ping_time.timestamp(), + interval=interval, + now=now.timestamp(), + ) + self.assertEqual(sleep_time, 4) + + class ManualPingTest(WebSocketBaseTestCase): def get_app(self): class PingHandler(TestWebSocketHandler): diff --git a/tornado/websocket.py b/tornado/websocket.py index b719547bd..c2e18d218 100644 --- a/tornado/websocket.py +++ b/tornado/websocket.py @@ -1346,6 +1346,11 @@ def start_pinging(self) -> None: ): self._ping_coroutine = asyncio.create_task(self.periodic_ping()) + @staticmethod + def ping_sleep_time(*, last_ping_time: float, interval: float, now: float) -> float: + """Calculate the sleep time until the next ping should be sent.""" + return max(0, last_ping_time + interval - now) + async def periodic_ping(self) -> None: """Send a ping and wait for a pong if ping_timeout is configured. @@ -1371,7 +1376,13 @@ async def periodic_ping(self) -> None: return # wait until the next scheduled ping - await asyncio.sleep(IOLoop.current().time() - ping_time + interval) + await asyncio.sleep( + self.ping_sleep_time( + last_ping_time=ping_time, + interval=interval, + now=IOLoop.current().time(), + ) + ) class WebSocketClientConnection(simple_httpclient._HTTPConnection):