bdarnell: This will *allow* coroutines for `on_message`, but by spawning a new callback for each one, those coroutines could run "concurrently" with no backpressure. I think it would be better (and more consistent with streaming callbacks in other parts of Tornado) if new messages couldn't be handled until the previous coroutine had returned (users could always opt in to calling spawn_callback themselves if they want that behavior). 

The simplest way to do this would be via plumbing in `_handle_message`, so we don't transition to the next `_receive_frame` state until the `on_message` callback has completed. That would block handling of control frames (ping, pong, close) while the callback is running. I think that's probably fine, although if we wanted to avoid it we could put the callback on a queue and not block the frame processing until the queue gets full. 
ajdavis: Done, and I hope I got it right. How should the new tests prove that the server is waiting for the on_message coroutine to finish? If I could override the write buffer size of the tests' WebSocketClientConnections, then maybe I could assert that the Future returned by `ws.write_message` in the tests isn't marked "done" until the on_message coroutine finishes?
jeyraof: When this feature will be deployed?
ajdavis: Hey Ben, I think this can go in, but take a look at my previous comment about testing.
diff --git a/tornado/websocket.py b/tornado/websocket.py
index 74358c90..754fca5c 100644
--- a/tornado/websocket.py
+++ b/tornado/websocket.py
@@ -30,7 +30,7 @@ import zlib
 
 from tornado.concurrent import TracebackFuture
 from tornado.escape import utf8, native_str, to_unicode
-from tornado import httpclient, httputil
+from tornado import gen, httpclient, httputil
 from tornado.ioloop import IOLoop, PeriodicCallback
 from tornado.iostream import StreamClosedError
 from tornado.log import gen_log, app_log
@@ -269,6 +269,10 @@ class WebSocketHandler(tornado.web.RequestHandler):
         """Handle incoming messages on the WebSocket
 
         This method must be overridden.
+
+        .. versionchanged:: 4.5
+
+           ``on_message`` can be a coroutine.
         """
         raise NotImplementedError
 
@@ -442,14 +446,21 @@ class WebSocketProtocol(object):
     def _run_callback(self, callback, *args, **kwargs):
         """Runs the given callback with exception handling.
 
-        On error, aborts the websocket connection and returns False.
+        If the callback is a coroutine, returns its Future. On error, aborts the
+        websocket connection and returns None.
         """
         try:
-            callback(*args, **kwargs)
+            result = callback(*args, **kwargs)
         except Exception:
             app_log.error("Uncaught exception in %s",
                           getattr(self.request, 'path', None), exc_info=True)
             self._abort()
+        else:
+            if result is not None:
+                self.stream.io_loop.add_future(gen.convert_yielded(result),
+                                               lambda f: f.result())
+
+            return result
 
     def on_connection_close(self):
         self._abort()
@@ -810,6 +821,8 @@ class WebSocketProtocol13(WebSocketProtocol):
         self._on_frame_data(_websocket_mask(self._frame_mask, data))
 
     def _on_frame_data(self, data):
+        handled_future = None
+
         self._wire_bytes_in += len(data)
         if self._frame_opcode_is_control:
             # control frames may be interleaved with a series of fragmented
@@ -842,12 +855,18 @@ class WebSocketProtocol13(WebSocketProtocol):
                 self._fragmented_message_buffer = data
 
         if self._final_frame:
-            self._handle_message(opcode, data)
+            handled_future = self._handle_message(opcode, data)
 
         if not self.client_terminated:
-            self._receive_frame()
+            if handled_future:
+                # on_message is a coroutine, process more frames once it's done.
+                gen.convert_yielded(handled_future).add_done_callback(
+                    lambda future: self._receive_frame())
+            else:
+                self._receive_frame()
 
     def _handle_message(self, opcode, data):
+        """Execute on_message, returning its Future if it is a coroutine."""
         if self.client_terminated:
             return
 
@@ -862,11 +881,11 @@ class WebSocketProtocol13(WebSocketProtocol):
             except UnicodeDecodeError:
                 self._abort()
                 return
-            self._run_callback(self.handler.on_message, decoded)
+            return self._run_callback(self.handler.on_message, decoded)
         elif opcode == 0x2:
             # Binary data
             self._message_bytes_in += len(data)
-            self._run_callback(self.handler.on_message, data)
+            return self._run_callback(self.handler.on_message, data)
         elif opcode == 0x8:
             # Close
             self.client_terminated = True
@@ -883,7 +902,7 @@ class WebSocketProtocol13(WebSocketProtocol):
         elif opcode == 0xA:
             # Pong
             self.last_pong = IOLoop.current().time()
-            self._run_callback(self.handler.on_pong, data)
+            return self._run_callback(self.handler.on_pong, data)
         else:
             self._abort()
 

WebSocketHandler.on_message should allow coroutines
I'd like to be able to use python 3.5's async/await syntax as much as possible. However, when I implement a WebSocketHandler and use async/await in the on_message handler, tornado gives me the following error:

`
/home/jaapz/Programming/mijnbaopt-websocket/.env/lib/python3.5/site-packages/tornado/websocket.py:417: RuntimeWarning: coroutine 'on_message' was never awaited
`

But when I use `tornado.gen.coroutine` as a decorator on the `on_message` method and use the `yield from` syntax inside it, everything works as expected.

The async/await syntax does work on the `prepare` method of the WebSocketHandler, so it would be nice if it'd also work on `on_message`.

diff --git a/tornado/test/websocket_test.py b/tornado/test/websocket_test.py
index 91f6692a..48390e6c 100644
--- a/tornado/test/websocket_test.py
+++ b/tornado/test/websocket_test.py
@@ -1,5 +1,6 @@
 from __future__ import absolute_import, division, print_function, with_statement
 
+import sys
 import traceback
 
 from tornado.concurrent import Future
@@ -7,7 +8,7 @@ from tornado import gen
 from tornado.httpclient import HTTPError, HTTPRequest
 from tornado.log import gen_log, app_log
 from tornado.testing import AsyncHTTPTestCase, gen_test, bind_unused_port, ExpectLog
-from tornado.test.util import unittest
+from tornado.test.util import unittest, skipBefore35, exec_test
 from tornado.web import Application, RequestHandler
 
 try:
@@ -92,6 +93,22 @@ class PathArgsHandler(TestWebSocketHandler):
         self.write_message(arg)
 
 
+class CoroutineOnMessageHandler(TestWebSocketHandler):
+    def initialize(self, close_future, compression_options=None):
+        super(CoroutineOnMessageHandler, self).initialize(close_future,
+                                                          compression_options)
+        self.sleeping = 0
+
+    @gen.coroutine
+    def on_message(self, message):
+        if self.sleeping > 0:
+            self.write_message('another coroutine is already sleeping')
+        self.sleeping += 1
+        yield gen.sleep(0.01)
+        self.sleeping -= 1
+        self.write_message(message)
+
+
 class WebSocketBaseTestCase(AsyncHTTPTestCase):
     @gen.coroutine
     def ws_connect(self, path, **kwargs):
@@ -126,6 +143,8 @@ class WebSocketTest(WebSocketBaseTestCase):
              dict(close_future=self.close_future)),
             ('/path_args/(.*)', PathArgsHandler,
              dict(close_future=self.close_future)),
+            ('/coroutine', CoroutineOnMessageHandler,
+             dict(close_future=self.close_future)),
         ])
 
     def test_http_request(self):
@@ -259,6 +278,17 @@ class WebSocketTest(WebSocketBaseTestCase):
         res = yield ws.read_message()
         self.assertEqual(res, 'hello')
 
+    @gen_test
+    def test_coroutine(self):
+        ws = yield self.ws_connect('/coroutine')
+        # Send both messages immediately, coroutine must process one at a time.
+        yield ws.write_message('hello1')
+        yield ws.write_message('hello2')
+        res = yield ws.read_message()
+        self.assertEqual(res, 'hello1')
+        res = yield ws.read_message()
+        self.assertEqual(res, 'hello2')
+
     @gen_test
     def test_check_origin_valid_no_path(self):
         port = self.get_http_port()
@@ -330,6 +360,42 @@ class WebSocketTest(WebSocketBaseTestCase):
         self.assertEqual(cm.exception.code, 403)
 
 
+if sys.version_info >= (3, 5):
+    NativeCoroutineOnMessageHandler = exec_test(globals(), locals(), """
+class NativeCoroutineOnMessageHandler(TestWebSocketHandler):
+    def initialize(self, close_future, compression_options=None):
+        super().initialize(close_future, compression_options)
+        self.sleeping = 0
+
+    async def on_message(self, message):
+        if self.sleeping > 0:
+            self.write_message('another coroutine is already sleeping')
+        self.sleeping += 1
+        await gen.sleep(0.01)
+        self.sleeping -= 1
+        self.write_message(message)""")['NativeCoroutineOnMessageHandler']
+
+
+class WebSocketNativeCoroutineTest(WebSocketBaseTestCase):
+    def get_app(self):
+        self.close_future = Future()
+        return Application([
+            ('/native', NativeCoroutineOnMessageHandler,
+             dict(close_future=self.close_future))])
+
+    @skipBefore35
+    @gen_test
+    def test_native_coroutine(self):
+        ws = yield self.ws_connect('/native')
+        # Send both messages immediately, coroutine must process one at a time.
+        yield ws.write_message('hello1')
+        yield ws.write_message('hello2')
+        res = yield ws.read_message()
+        self.assertEqual(res, 'hello1')
+        res = yield ws.read_message()
+        self.assertEqual(res, 'hello2')
+
+
 class CompressionTestMixin(object):
     MESSAGE = 'Hello world. Testing 123 123'
 

pip install -e .[dev]
parse_log_pytest
pytest
sphinx sphinx_rtd_theme pytest
apt-get update apt-get install -y gcc
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
Babel==2.11.0
certifi==2021.5.30
charset-normalizer==2.0.12
docutils==0.18.1
idna==3.10
imagesize==1.4.1
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1631916693255/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
Jinja2==3.0.3
MarkupSafe==2.0.1
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
Pygments==2.14.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
pytz==2025.2
requests==2.27.1
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
-e git+https://github.com/tornadoweb/tornado.git@e15dbaa53fa21e31cbda9a6b5e53b795b9919496#egg=tornado
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
urllib3==1.26.20
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: tornado
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib-metadata=4.8.1=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - babel==2.11.0
      - charset-normalizer==2.0.12
      - docutils==0.18.1
      - idna==3.10
      - imagesize==1.4.1
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - pygments==2.14.0
      - pytz==2025.2
      - requests==2.27.1
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - urllib3==1.26.20
prefix: /opt/conda/envs/tornado

tornado/test/websocket_test.py::WebSocketNativeCoroutineTest::test_native_coroutine
tornado/test/websocket_test.py::WebSocketTest::test_async_prepare tornado/test/websocket_test.py::WebSocketTest::test_binary_message tornado/test/websocket_test.py::WebSocketTest::test_check_origin_invalid tornado/test/websocket_test.py::WebSocketTest::test_check_origin_invalid_partial_url tornado/test/websocket_test.py::WebSocketTest::test_check_origin_invalid_subdomains tornado/test/websocket_test.py::WebSocketTest::test_check_origin_valid_no_path tornado/test/websocket_test.py::WebSocketTest::test_check_origin_valid_with_path tornado/test/websocket_test.py::WebSocketTest::test_client_close_reason tornado/test/websocket_test.py::WebSocketTest::test_coroutine tornado/test/websocket_test.py::WebSocketTest::test_error_in_on_message tornado/test/websocket_test.py::WebSocketTest::test_http_request tornado/test/websocket_test.py::WebSocketTest::test_path_args tornado/test/websocket_test.py::WebSocketTest::test_server_close_reason tornado/test/websocket_test.py::WebSocketTest::test_unicode_message tornado/test/websocket_test.py::WebSocketTest::test_websocket_callbacks tornado/test/websocket_test.py::WebSocketTest::test_websocket_close_buffered_data tornado/test/websocket_test.py::WebSocketTest::test_websocket_gen tornado/test/websocket_test.py::WebSocketTest::test_websocket_headers tornado/test/websocket_test.py::WebSocketTest::test_websocket_http_fail tornado/test/websocket_test.py::WebSocketTest::test_websocket_http_success tornado/test/websocket_test.py::WebSocketTest::test_websocket_network_fail tornado/test/websocket_test.py::NoCompressionTest::test_message_sizes tornado/test/websocket_test.py::ServerOnlyCompressionTest::test_message_sizes tornado/test/websocket_test.py::ClientOnlyCompressionTest::test_message_sizes tornado/test/websocket_test.py::DefaultCompressionTest::test_message_sizes tornado/test/websocket_test.py::PythonMaskFunctionTest::test_mask tornado/test/websocket_test.py::CythonMaskFunctionTest::test_mask tornado/test/websocket_test.py::ServerPeriodicPingTest::test_server_ping tornado/test/websocket_test.py::ClientPeriodicPingTest::test_client_ping
tornado/websocket.py
tornado/websocket.py