Skip to content

Commit bfc5716

Browse files
websockets: fix ping_timeout
* Closes #3258 * Closes #2655 * Fixes an issue with the calculation of ping timeout interval that could cause connections to be erroneously timed out and closed from the server end.
1 parent d92390e commit bfc5716

File tree

2 files changed

+157
-44
lines changed

2 files changed

+157
-44
lines changed

tornado/test/websocket_test.py

+71-2
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,11 @@ class PingHandler(TestWebSocketHandler):
810810
def on_pong(self, data):
811811
self.write_message("got pong")
812812

813-
return Application([("/", PingHandler)], websocket_ping_interval=0.01)
813+
return Application(
814+
[("/", PingHandler)],
815+
websocket_ping_interval=0.01,
816+
websocket_ping_timeout=0,
817+
)
814818

815819
@gen_test
816820
def test_server_ping(self):
@@ -831,14 +835,79 @@ def on_ping(self, data):
831835

832836
@gen_test
833837
def test_client_ping(self):
834-
ws = yield self.ws_connect("/", ping_interval=0.01)
838+
ws = yield self.ws_connect("/", ping_interval=0.01, ping_timeout=0)
835839
for i in range(3):
836840
response = yield ws.read_message()
837841
self.assertEqual(response, "got ping")
838842
# TODO: test that the connection gets closed if ping responses stop.
839843
ws.close()
840844

841845

846+
class ServerPingTimeoutTest(WebSocketBaseTestCase):
847+
def get_app(self):
848+
handlers = []
849+
850+
class PingHandler(TestWebSocketHandler):
851+
def initialize(self, close_future=None, compression_options=None):
852+
# capture the handler instance so we can interrogate it later
853+
nonlocal handlers
854+
handlers.append(self)
855+
return super().initialize(
856+
close_future=close_future,
857+
compression_options=compression_options,
858+
)
859+
860+
app = Application([("/", PingHandler)])
861+
app._handlers = handlers
862+
return app
863+
864+
@staticmethod
865+
def delay_pong(ws, delay):
866+
"""Delay the client's "pong" response by <delay> seconds."""
867+
def wrapper(fcn):
868+
def _inner(oppcode: int, data: bytes):
869+
# print(f'$ {oppcode} {data}') # uncomment to debug
870+
if oppcode == 0xA: # NOTE: 0x9=ping, 0xA=pong
871+
from time import sleep
872+
sleep(delay)
873+
return fcn(oppcode, data)
874+
return _inner
875+
876+
ws.protocol._handle_message = wrapper(ws.protocol._handle_message)
877+
878+
@gen_test
879+
def test_client_ping_timeout(self):
880+
# websocket client
881+
ws = yield self.ws_connect("/", ping_interval=0.2, ping_timeout=0.05)
882+
883+
# websocket handler (server side)
884+
handler = self._app._handlers[0]
885+
886+
for _ in range(5):
887+
# wait for the ping period
888+
yield gen.sleep(0.2)
889+
890+
# connection should still be open from the server end
891+
assert handler.close_code is None
892+
assert handler.close_reason is None
893+
894+
# connection should still be open from the client end
895+
assert ws.protocol.close_code is None
896+
897+
# delay the pong message by 0.10 seconds (timeout=0.05)
898+
self.delay_pong(ws, 0.1)
899+
900+
# give the server time to register this
901+
yield gen.sleep(0.2)
902+
903+
# connection should be closed from the server side
904+
assert handler.close_code == 1000
905+
assert handler.close_reason == 'ping timed out'
906+
907+
# client should have received a close operation
908+
assert ws.protocol.close_code == 1000
909+
910+
842911
class ManualPingTest(WebSocketBaseTestCase):
843912
def get_app(self):
844913
class PingHandler(TestWebSocketHandler):

tornado/websocket.py

+86-42
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
import abc
1515
import asyncio
1616
import base64
17+
import functools
1718
import hashlib
19+
import logging
1820
import os
1921
import sys
2022
import 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

101106
class 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'\nSetting 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

13381382
class WebSocketClientConnection(simple_httpclient._HTTPConnection):

0 commit comments

Comments
 (0)