Repository: tornadoweb/tornado
Pull request #2178 (2017-10-22 19:56:27)

Fixes for IOLoop.run_in_executor
Correctly transform the concurrent Future into a Tornado Future
and test that the result is usable with await.

cc @mivade (chain_future was in your initial implementation but I think I gave you some confusing feedback and it got lost along the way)


Patch:
diff --git a/docs/index.rst b/docs/index.rst
index ed24eb62..a5f166a3 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -66,10 +66,9 @@ installed in this way, so you may wish to download a copy of the
 source tarball or clone the `git repository
 <https://github.com/tornadoweb/tornado>`_ as well.
 
-**Prerequisites**: Tornado runs on Python 2.7, and 3.4+.
-The updates to the `ssl` module in Python 2.7.9 are required
-(in some distributions, these updates may be available in
-older python versions). In addition to the requirements
+**Prerequisites**: Tornado runs on Python 2.7, and 3.4+
+For Python 2, version 2.7.9 or newer is *strongly*
+recommended for the improved SSL support. In addition to the requirements
 which will be installed automatically by ``pip`` or ``setup.py install``,
 the following optional packages may be useful:
 
diff --git a/maint/requirements.in b/maint/requirements.in
index eeb2f4d6..64beb5fc 100644
--- a/maint/requirements.in
+++ b/maint/requirements.in
@@ -1,7 +1,9 @@
 # Requirements for tools used in the development of tornado.
 # This list is for python 3.5; for 2.7 add:
+# - backports.ssl-match-hostname
 # - futures
 # - mock
+# - certifi
 #
 # Use virtualenv instead of venv; tox seems to get confused otherwise.
 #
diff --git a/setup.py b/setup.py
index 26bb716d..72569a1f 100644
--- a/setup.py
+++ b/setup.py
@@ -16,7 +16,6 @@
 
 import os
 import platform
-import ssl
 import sys
 import warnings
 
@@ -127,22 +126,21 @@ if (platform.python_implementation() == 'CPython' and
 if setuptools is not None:
     # If setuptools is not available, you're on your own for dependencies.
     install_requires = []
+    if sys.version_info < (2, 7):
+        # Only needed indirectly, for singledispatch.
+        install_requires.append('ordereddict')
+    if sys.version_info < (2, 7, 9):
+        install_requires.append('backports.ssl_match_hostname')
     if sys.version_info < (3, 4):
         install_requires.append('singledispatch')
+        # Certifi is also optional on 2.7.9+, although making our dependencies
+        # conditional on micro version numbers seems like a bad idea
+        # until we have more declarative metadata.
+        install_requires.append('certifi')
     if sys.version_info < (3, 5):
         install_requires.append('backports_abc>=0.4')
     kwargs['install_requires'] = install_requires
 
-# Verify that the SSL module has all the modern upgrades. Check for several
-# names individually since they were introduced at different versions,
-# although they should all be present by Python 3.4 or 2.7.9.
-if (not hasattr(ssl, 'SSLContext') or
-        not hasattr(ssl, 'create_default_context') or
-        not hasattr(ssl, 'match_hostname')):
-    raise ImportError("Tornado requires an up-to-date SSL module. This means "
-                      "Python 2.7.9+ or 3.4+ (although some distributions have "
-                      "backported the necessary changes to older versions).")
-
 setup(
     name="tornado",
     version=version,
diff --git a/tornado/ioloop.py b/tornado/ioloop.py
index 5686576c..a60632e4 100644
--- a/tornado/ioloop.py
+++ b/tornado/ioloop.py
@@ -44,7 +44,7 @@ import time
 import traceback
 import math
 
-from tornado.concurrent import TracebackFuture, is_future
+from tornado.concurrent import TracebackFuture, is_future, chain_future
 from tornado.log import app_log, gen_log
 from tornado.platform.auto import set_close_exec, Waker
 from tornado import stack_context
@@ -655,8 +655,12 @@ class IOLoop(Configurable):
                 from tornado.process import cpu_count
                 self._executor = ThreadPoolExecutor(max_workers=(cpu_count() * 5))
             executor = self._executor
-
-        return executor.submit(func, *args)
+        c_future = executor.submit(func, *args)
+        # Concurrent Futures are not usable with await. Wrap this in a
+        # Tornado Future instead, using self.add_future for thread-safety.
+        t_future = TracebackFuture()
+        self.add_future(c_future, lambda f: chain_future(f, t_future))
+        return t_future
 
     def set_default_executor(self, executor):
         """Sets the default executor to use with :meth:`run_in_executor`."""
diff --git a/tornado/iostream.py b/tornado/iostream.py
index 2c2c358c..2026ae57 100644
--- a/tornado/iostream.py
+++ b/tornado/iostream.py
@@ -37,7 +37,7 @@ import re
 from tornado.concurrent import TracebackFuture
 from tornado import ioloop
 from tornado.log import gen_log, app_log
-from tornado.netutil import ssl_wrap_socket, _client_ssl_defaults, _server_ssl_defaults
+from tornado.netutil import ssl_wrap_socket, ssl_match_hostname, SSLCertificateError, _client_ssl_defaults, _server_ssl_defaults
 from tornado import stack_context
 from tornado.util import errno_from_exception
 
@@ -167,8 +167,10 @@ class BaseIOStream(object):
         self._write_buffer = bytearray()
         self._write_buffer_pos = 0
         self._write_buffer_size = 0
+        self._write_buffer_frozen = False
         self._total_write_index = 0
         self._total_write_done_index = 0
+        self._pending_writes_while_frozen = []
         self._read_delimiter = None
         self._read_regex = None
         self._read_max_bytes = None
@@ -388,8 +390,11 @@ class BaseIOStream(object):
             if (self.max_write_buffer_size is not None and
                     self._write_buffer_size + len(data) > self.max_write_buffer_size):
                 raise StreamBufferFullError("Reached maximum write buffer size")
-            self._write_buffer += data
-            self._write_buffer_size += len(data)
+            if self._write_buffer_frozen:
+                self._pending_writes_while_frozen.append(data)
+            else:
+                self._write_buffer += data
+                self._write_buffer_size += len(data)
             self._total_write_index += len(data)
         if callback is not None:
             self._write_callback = stack_context.wrap(callback)
@@ -827,12 +832,29 @@ class BaseIOStream(object):
                 "delimiter %r not found within %d bytes" % (
                     delimiter, self._read_max_bytes))
 
+    def _freeze_write_buffer(self, size):
+        self._write_buffer_frozen = size
+
+    def _unfreeze_write_buffer(self):
+        self._write_buffer_frozen = False
+        self._write_buffer += b''.join(self._pending_writes_while_frozen)
+        self._write_buffer_size += sum(map(len, self._pending_writes_while_frozen))
+        self._pending_writes_while_frozen[:] = []
+
+    def _got_empty_write(self, size):
+        """
+        Called when a non-blocking write() failed writing anything.
+        Can be overridden in subclasses.
+        """
+
     def _handle_write(self):
         while self._write_buffer_size:
             assert self._write_buffer_size >= 0
             try:
                 start = self._write_buffer_pos
-                if _WINDOWS:
+                if self._write_buffer_frozen:
+                    size = self._write_buffer_frozen
+                elif _WINDOWS:
                     # On windows, socket.send blows up if given a
                     # write buffer that's too large, instead of just
                     # returning the number of bytes it was able to
@@ -844,6 +866,7 @@ class BaseIOStream(object):
                 num_bytes = self.write_to_fd(
                     memoryview(self._write_buffer)[start:start + size])
                 if num_bytes == 0:
+                    self._got_empty_write(size)
                     break
                 self._write_buffer_pos += num_bytes
                 self._write_buffer_size -= num_bytes
@@ -853,9 +876,12 @@ class BaseIOStream(object):
                 if self._write_buffer_pos > self._write_buffer_size:
                     del self._write_buffer[:self._write_buffer_pos]
                     self._write_buffer_pos = 0
+                if self._write_buffer_frozen:
+                    self._unfreeze_write_buffer()
                 self._total_write_done_index += num_bytes
             except (socket.error, IOError, OSError) as e:
                 if e.args[0] in _ERRNO_WOULDBLOCK:
+                    self._got_empty_write(size)
                     break
                 else:
                     if not self._is_connreset(e):
@@ -1274,6 +1300,17 @@ class SSLIOStream(IOStream):
     def writing(self):
         return self._handshake_writing or super(SSLIOStream, self).writing()
 
+    def _got_empty_write(self, size):
+        # With OpenSSL, if we couldn't write the entire buffer,
+        # the very same string object must be used on the
+        # next call to send.  Therefore we suppress
+        # merging the write buffer after an incomplete send.
+        # A cleaner solution would be to set
+        # SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER, but this is
+        # not yet accessible from python
+        # (http://bugs.python.org/issue8240)
+        self._freeze_write_buffer(size)
+
     def _do_ssl_handshake(self):
         # Based on code from test_ssl.py in the python stdlib
         try:
@@ -1350,8 +1387,8 @@ class SSLIOStream(IOStream):
             gen_log.warning("No SSL certificate given")
             return False
         try:
-            ssl.match_hostname(peercert, self._server_hostname)
-        except ssl.CertificateError as e:
+            ssl_match_hostname(peercert, self._server_hostname)
+        except SSLCertificateError as e:
             gen_log.warning("Invalid SSL certificate: %s" % e)
             return False
         else:
diff --git a/tornado/netutil.py b/tornado/netutil.py
index 1469b90c..c054f625 100644
--- a/tornado/netutil.py
+++ b/tornado/netutil.py
@@ -35,20 +35,42 @@ except ImportError:
     # ssl is not available on Google App Engine
     ssl = None
 
+try:
+    import certifi
+except ImportError:
+    # certifi is optional as long as we have ssl.create_default_context.
+    if ssl is None or hasattr(ssl, 'create_default_context'):
+        certifi = None
+    else:
+        raise
+
 if PY3:
     xrange = range
 
-if ssl is not None:
-    # Note that the naming of ssl.Purpose is confusing; the purpose
-    # of a context is to authentiate the opposite side of the connection.
-    _client_ssl_defaults = ssl.create_default_context(
-        ssl.Purpose.SERVER_AUTH)
-    _server_ssl_defaults = ssl.create_default_context(
-        ssl.Purpose.CLIENT_AUTH)
-    if hasattr(ssl, 'OP_NO_COMPRESSION'):
-        # See netutil.ssl_options_to_context
-        _client_ssl_defaults.options |= ssl.OP_NO_COMPRESSION
-        _server_ssl_defaults.options |= ssl.OP_NO_COMPRESSION
+if hasattr(ssl, 'match_hostname') and hasattr(ssl, 'CertificateError'):  # python 3.2+
+    ssl_match_hostname = ssl.match_hostname
+    SSLCertificateError = ssl.CertificateError
+elif ssl is None:
+    ssl_match_hostname = SSLCertificateError = None  # type: ignore
+else:
+    import backports.ssl_match_hostname
+    ssl_match_hostname = backports.ssl_match_hostname.match_hostname
+    SSLCertificateError = backports.ssl_match_hostname.CertificateError  # type: ignore
+
+if hasattr(ssl, 'SSLContext'):
+    if hasattr(ssl, 'create_default_context'):
+        # Python 2.7.9+, 3.4+
+        # Note that the naming of ssl.Purpose is confusing; the purpose
+        # of a context is to authentiate the opposite side of the connection.
+        _client_ssl_defaults = ssl.create_default_context(
+            ssl.Purpose.SERVER_AUTH)
+        _server_ssl_defaults = ssl.create_default_context(
+            ssl.Purpose.CLIENT_AUTH)
+elif ssl:
+    # Python 2.6-2.7.8
+    _client_ssl_defaults = dict(cert_reqs=ssl.CERT_REQUIRED,
+                                ca_certs=certifi.where())
+    _server_ssl_defaults = {}
 else:
     # Google App Engine
     _client_ssl_defaults = dict(cert_reqs=None,
@@ -465,12 +487,11 @@ def ssl_options_to_context(ssl_options):
     accepts both forms needs to upgrade to the `~ssl.SSLContext` version
     to use features like SNI or NPN.
     """
-    if isinstance(ssl_options, ssl.SSLContext):
+    if isinstance(ssl_options, dict):
+        assert all(k in _SSL_CONTEXT_KEYWORDS for k in ssl_options), ssl_options
+    if (not hasattr(ssl, 'SSLContext') or
+            isinstance(ssl_options, ssl.SSLContext)):
         return ssl_options
-    assert isinstance(ssl_options, dict)
-    assert all(k in _SSL_CONTEXT_KEYWORDS for k in ssl_options), ssl_options
-    # Can't use create_default_context since this interface doesn't
-    # tell us client vs server.
     context = ssl.SSLContext(
         ssl_options.get('ssl_version', ssl.PROTOCOL_SSLv23))
     if 'certfile' in ssl_options:
@@ -483,9 +504,7 @@ def ssl_options_to_context(ssl_options):
         context.set_ciphers(ssl_options['ciphers'])
     if hasattr(ssl, 'OP_NO_COMPRESSION'):
         # Disable TLS compression to avoid CRIME and related attacks.
-        # This constant depends on openssl version 1.0.
-        # TODO: Do we need to do this ourselves or can we trust
-        # the defaults?
+        # This constant wasn't added until python 3.3.
         context.options |= ssl.OP_NO_COMPRESSION
     return context
 
@@ -500,13 +519,14 @@ def ssl_wrap_socket(socket, ssl_options, server_hostname=None, **kwargs):
     appropriate).
     """
     context = ssl_options_to_context(ssl_options)
-    if ssl.HAS_SNI:
-        # In python 3.4, wrap_socket only accepts the server_hostname
-        # argument if HAS_SNI is true.
-        # TODO: add a unittest (python added server-side SNI support in 3.4)
-        # In the meantime it can be manually tested with
-        # python3 -m tornado.httpclient https://sni.velox.ch
-        return context.wrap_socket(socket, server_hostname=server_hostname,
-                                   **kwargs)
+    if hasattr(ssl, 'SSLContext') and isinstance(context, ssl.SSLContext):
+        if server_hostname is not None and getattr(ssl, 'HAS_SNI'):
+            # Python doesn't have server-side SNI support so we can't
+            # really unittest this, but it can be manually tested with
+            # python3.2 -m tornado.httpclient https://sni.velox.ch
+            return context.wrap_socket(socket, server_hostname=server_hostname,
+                                       **kwargs)
+        else:
+            return context.wrap_socket(socket, **kwargs)
     else:
-        return context.wrap_socket(socket, **kwargs)
+        return ssl.wrap_socket(socket, **dict(context, **kwargs))  # type: ignore
diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py
index 6d90352f..f394689d 100644
--- a/tornado/simple_httpclient.py
+++ b/tornado/simple_httpclient.py
@@ -35,6 +35,18 @@ except ImportError:
     # ssl is not available on Google App Engine.
     ssl = None
 
+try:
+    import certifi
+except ImportError:
+    certifi = None
+
+
+def _default_ca_certs():
+    if certifi is None:
+        raise Exception("The 'certifi' package is required to use https "
+                        "in simple_httpclient")
+    return certifi.where()
+
 
 class SimpleAsyncHTTPClient(AsyncHTTPClient):
     """Non-blocking HTTP client with no external dependencies.
@@ -244,19 +256,42 @@ class _HTTPConnection(httputil.HTTPMessageDelegate):
                     self.request.client_cert is None and
                     self.request.client_key is None):
                 return _client_ssl_defaults
-            ssl_ctx = ssl.create_default_context(
-                ssl.Purpose.SERVER_AUTH,
-                cafile=self.request.ca_certs)
-            if not self.request.validate_cert:
-                ssl_ctx.check_hostname = False
-                ssl_ctx.verify_mode = ssl.CERT_NONE
+            ssl_options = {}
+            if self.request.validate_cert:
+                ssl_options["cert_reqs"] = ssl.CERT_REQUIRED
+            if self.request.ca_certs is not None:
+                ssl_options["ca_certs"] = self.request.ca_certs
+            elif not hasattr(ssl, 'create_default_context'):
+                # When create_default_context is present,
+                # we can omit the "ca_certs" parameter entirely,
+                # which avoids the dependency on "certifi" for py34.
+                ssl_options["ca_certs"] = _default_ca_certs()
+            if self.request.client_key is not None:
+                ssl_options["keyfile"] = self.request.client_key
             if self.request.client_cert is not None:
-                ssl_ctx.load_cert_chain(self.request.client_cert,
-                                        self.request.client_key)
-            if hasattr(ssl, 'OP_NO_COMPRESSION'):
-                # See netutil.ssl_options_to_context
-                ssl_ctx.options |= ssl.OP_NO_COMPRESSION
-            return ssl_ctx
+                ssl_options["certfile"] = self.request.client_cert
+
+            # SSL interoperability is tricky.  We want to disable
+            # SSLv2 for security reasons; it wasn't disabled by default
+            # until openssl 1.0.  The best way to do this is to use
+            # the SSL_OP_NO_SSLv2, but that wasn't exposed to python
+            # until 3.2.  Python 2.7 adds the ciphers argument, which
+            # can also be used to disable SSLv2.  As a last resort
+            # on python 2.6, we set ssl_version to TLSv1.  This is
+            # more narrow than we'd like since it also breaks
+            # compatibility with servers configured for SSLv3 only,
+            # but nearly all servers support both SSLv3 and TLSv1:
+            # http://blog.ivanristic.com/2011/09/ssl-survey-protocol-support.html
+            if sys.version_info >= (2, 7):
+                # In addition to disabling SSLv2, we also exclude certain
+                # classes of insecure ciphers.
+                ssl_options["ciphers"] = "DEFAULT:!SSLv2:!EXPORT:!DES"
+            else:
+                # This is really only necessary for pre-1.0 versions
+                # of openssl, but python 2.6 doesn't expose version
+                # information.
+                ssl_options["ssl_version"] = ssl.PROTOCOL_TLSv1
+            return ssl_options
         return None
 
     def _on_timeout(self, info=None):


Test patch:
diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py
index 2564e83b..4444a392 100644
--- a/tornado/test/httpserver_test.py
+++ b/tornado/test/httpserver_test.py
@@ -150,6 +150,7 @@ class TLSv1Test(BaseSSLTest, SSLTestMixin):
         return ssl.PROTOCOL_TLSv1
 
 
+@unittest.skipIf(not hasattr(ssl, 'SSLContext'), 'ssl.SSLContext not present')
 class SSLContextTest(BaseSSLTest, SSLTestMixin):
     def get_ssl_options(self):
         context = ssl_options_to_context(
diff --git a/tornado/test/ioloop_test.py b/tornado/test/ioloop_test.py
index f3cd32ae..603eb202 100644
--- a/tornado/test/ioloop_test.py
+++ b/tornado/test/ioloop_test.py
@@ -604,56 +604,70 @@ class TestIOLoopFutures(AsyncTestCase):
         event1 = threading.Event()
         event2 = threading.Event()
 
-        def callback(self_event, other_event):
+        def sync_func(self_event, other_event):
             self_event.set()
-            time.sleep(0.01)
-            self.assertTrue(other_event.is_set())
+            other_event.wait()
+            # Note that return value doesn't actually do anything,
+            # it is just passed through to our final assertion to
+            # make sure it is passed through properly.
             return self_event
 
+        # Run two synchronous functions, which would deadlock if not
+        # run in parallel.
         res = yield [
-            IOLoop.current().run_in_executor(None, callback, event1, event2),
-            IOLoop.current().run_in_executor(None, callback, event2, event1)
+            IOLoop.current().run_in_executor(None, sync_func, event1, event2),
+            IOLoop.current().run_in_executor(None, sync_func, event2, event1)
         ]
 
         self.assertEqual([event1, event2], res)
 
     @skipBefore35
+    @gen_test
     def test_run_in_executor_native(self):
         event1 = threading.Event()
         event2 = threading.Event()
 
-        def callback(self_event, other_event):
+        def sync_func(self_event, other_event):
             self_event.set()
-            time.sleep(0.01)
-            self.assertTrue(other_event.is_set())
             other_event.wait()
             return self_event
 
+        # Go through an async wrapper to ensure that the result of
+        # run_in_executor works with await and not just gen.coroutine
+        # (simply passing the underlying concurrrent future would do that).
         namespace = exec_test(globals(), locals(), """
-            async def main():
-                res = await gen.multi([
-                    IOLoop.current().run_in_executor(None, callback, event1, event2),
-                    IOLoop.current().run_in_executor(None, callback, event2, event1)
-                ])
-                self.assertEqual([event1, event2], res)
+            async def async_wrapper(self_event, other_event):
+                return await IOLoop.current().run_in_executor(
+                    None, sync_func, self_event, other_event)
         """)
-        IOLoop.current().run_sync(namespace['main'])
 
+        res = yield [
+            namespace["async_wrapper"](event1, event2),
+            namespace["async_wrapper"](event2, event1)
+            ]
+
+        self.assertEqual([event1, event2], res)
+
+    @gen_test
     def test_set_default_executor(self):
-        class MyExecutor(futures.Executor):
+        count = [0]
+
+        class MyExecutor(futures.ThreadPoolExecutor):
             def submit(self, func, *args):
-                return Future()
+                count[0] += 1
+                return super(MyExecutor, self).submit(func, *args)
 
         event = threading.Event()
 
-        def future_func():
+        def sync_func():
             event.set()
 
-        executor = MyExecutor()
+        executor = MyExecutor(1)
         loop = IOLoop.current()
         loop.set_default_executor(executor)
-        loop.run_in_executor(None, future_func)
-        loop.add_timeout(0.01, lambda: self.assertFalse(event.is_set()))
+        yield loop.run_in_executor(None, sync_func)
+        self.assertEqual(1, count[0])
+        self.assertTrue(event.is_set())
 
 
 class TestIOLoopRunSync(unittest.TestCase):
diff --git a/tornado/test/iostream_test.py b/tornado/test/iostream_test.py
index 4df23dd2..751dfe2e 100644
--- a/tornado/test/iostream_test.py
+++ b/tornado/test/iostream_test.py
@@ -880,6 +880,7 @@ class TestIOStreamSSL(TestIOStreamMixin, AsyncTestCase):
 # This will run some tests that are basically redundant but it's the
 # simplest way to make sure that it works to pass an SSLContext
 # instead of an ssl_options dict to the SSLIOStream constructor.
+@unittest.skipIf(not hasattr(ssl, 'SSLContext'), 'ssl.SSLContext not present')
 class TestIOStreamSSLContext(TestIOStreamMixin, AsyncTestCase):
     def _make_server_iostream(self, connection, **kwargs):
         context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
@@ -982,6 +983,8 @@ class TestIOStreamStartTLS(AsyncTestCase):
         with self.assertRaises((ssl.SSLError, socket.error)):
             yield server_future
 
+    @unittest.skipIf(not hasattr(ssl, 'create_default_context'),
+                     'ssl.create_default_context not present')
     @gen_test
     def test_check_hostname(self):
         # Test that server_hostname parameter to start_tls is being used.
diff --git a/tornado/test/runtests.py b/tornado/test/runtests.py
index 2548058d..7cabbaf1 100644
--- a/tornado/test/runtests.py
+++ b/tornado/test/runtests.py
@@ -118,6 +118,10 @@ def main():
     # 2.7 and 3.2
     warnings.filterwarnings("ignore", category=DeprecationWarning,
                             message="Please use assert.* instead")
+    # unittest2 0.6 on py26 reports these as PendingDeprecationWarnings
+    # instead of DeprecationWarnings.
+    warnings.filterwarnings("ignore", category=PendingDeprecationWarning,
+                            message="Please use assert.* instead")
     # Twisted 15.0.0 triggers some warnings on py3 with -bb.
     warnings.filterwarnings("ignore", category=BytesWarning,
                             module=r"twisted\..*")
diff --git a/tornado/test/simple_httpclient_test.py b/tornado/test/simple_httpclient_test.py
index 309678ef..f7133143 100644
--- a/tornado/test/simple_httpclient_test.py
+++ b/tornado/test/simple_httpclient_test.py
@@ -497,6 +497,8 @@ class SimpleHTTPSClientTestCase(SimpleHTTPClientTestMixin, AsyncHTTPSTestCase):
         resp = self.fetch("/hello", ssl_options={})
         self.assertEqual(resp.body, b"Hello world!")
 
+    @unittest.skipIf(not hasattr(ssl, 'SSLContext'),
+                     'ssl.SSLContext not present')
     def test_ssl_context(self):
         resp = self.fetch("/hello",
                           ssl_options=ssl.SSLContext(ssl.PROTOCOL_SSLv23))
@@ -509,6 +511,8 @@ class SimpleHTTPSClientTestCase(SimpleHTTPClientTestMixin, AsyncHTTPSTestCase):
                 "/hello", ssl_options=dict(cert_reqs=ssl.CERT_REQUIRED))
         self.assertRaises(ssl.SSLError, resp.rethrow)
 
+    @unittest.skipIf(not hasattr(ssl, 'SSLContext'),
+                     'ssl.SSLContext not present')
     def test_ssl_context_handshake_fail(self):
         with ExpectLog(gen_log, "SSL Error|Uncaught exception"):
             ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
diff --git a/tornado/test/twisted_test.py b/tornado/test/twisted_test.py
index c0a243a9..4b88eca8 100644
--- a/tornado/test/twisted_test.py
+++ b/tornado/test/twisted_test.py
@@ -72,6 +72,9 @@ else:
 skipIfNoTwisted = unittest.skipUnless(have_twisted,
                                       "twisted module not present")
 
+skipIfPy26 = unittest.skipIf(sys.version_info < (2, 7),
+                             "twisted incompatible with singledispatch in py26")
+
 
 def save_signal_handlers():
     saved = {}
@@ -491,6 +494,7 @@ class CompatibilityTests(unittest.TestCase):
             'http://127.0.0.1:%d' % self.tornado_port, self.run_reactor)
         self.assertEqual(response, 'Hello from tornado!')
 
+    @skipIfPy26
     def testTornadoServerTwistedCoroutineClientIOLoop(self):
         self.start_tornado_server()
         response = self.twisted_coroutine_fetch(
@@ -499,6 +503,7 @@ class CompatibilityTests(unittest.TestCase):
 
 
 @skipIfNoTwisted
+@skipIfPy26
 class ConvertDeferredTest(unittest.TestCase):
     def test_success(self):
         @inlineCallbacks
diff --git a/tornado/test/util.py b/tornado/test/util.py
index 0a9438ba..19887c10 100644
--- a/tornado/test/util.py
+++ b/tornado/test/util.py
@@ -9,8 +9,15 @@ import textwrap
 
 from tornado.testing import bind_unused_port
 
-# Delegate the choice of unittest or unittest2 to tornado.testing.
-from tornado.testing import unittest
+# Encapsulate the choice of unittest or unittest2 here.
+# To be used as 'from tornado.test.util import unittest'.
+if sys.version_info < (2, 7):
+    # In py26, we must always use unittest2.
+    import unittest2 as unittest  # type: ignore
+else:
+    # Otherwise, use whichever version of unittest was imported in
+    # tornado.testing.
+    from tornado.testing import unittest
 
 skipIfNonUnix = unittest.skipIf(os.name != 'posix' or sys.platform == 'cygwin',
                                 "non-unix platform")