Repository: tornadoweb/tornado

Issue:
IOStream.error is often wrong
Hi Ben, I know that adding sys.exc_info() as IOStream.error was my idea, but in my experience I was wrong to recommend it. The following example prints, "closed, error: integer division or modulo by zero":

``` python
from datetime import timedelta
from tornado import gen, ioloop, iostream
import socket

loop = ioloop.IOLoop.instance()

@gen.engine
def f():
    sock = socket.socket()
    stream = iostream.IOStream(sock)
    yield gen.Task(stream.connect, ('tornadoweb.org', 80))

    try: 1 / 0
    except ZeroDivisionError: pass

    def closed():
        print 'closed, error:', stream.error

    stream.set_close_callback(closed)
    stream.close()
    loop.add_timeout(timedelta(seconds=1), loop.stop)

f()
loop.start()
```

I believe IOStream.error is correct if and only if IOStream.close() is called from within an exception handler.

I'm not sure the best next step, I propose either:
- Add an arg `store_error=False` to `close()`, which if True tells the stream to assign `self.error` from `sys.exc_info()`. Call `close(store_error=True)` within exception handlers, and just `close()` elsewhere.
- Remove IOStream.error, turns out it wasn't a good idea



Discussion:
I think your first option is the way to go - the error information is still useful (especially since I've been using its existence as an excuse to remove some noisy logging).  I'd probably call the argument exc_info and give it the same semantics as the logging module's exc_info argument.

This also reveals an interesting quirk of the generator implementation.  In a normal function sys.exc_info would have been cleared after the "except ZeroDivisionError: pass", but in a generator the exception state persists (presumably until the next yield).  

Cool, shall I make a patch?

On Sat, Dec 8, 2012 at 10:51 AM, bdarnell notifications@github.com wrote:

> I think your first option is the way to go - the error information is
> still useful (especially since I've been using its existence as an excuse
> to remove some noisy logging). I'd probably call the argument exc_info and
> give it the same semantics as the logging module's exc_info argument.
> 
> This also reveals an interesting quirk of the generator implementation. In
> a normal function sys.exc_info would have been cleared after the "except
> ZeroDivisionError: pass", but in a generator the exception state persists
> (presumably until the next yield).
> 
> —
> Reply to this email directly or view it on GitHubhttps://github.com/facebook/tornado/issues/651#issuecomment-11159922.

Go for it.

OK, should I extend the 2.4 branch or extend the master branch?

On Sat, Dec 8, 2012 at 4:55 PM, bdarnell notifications@github.com wrote:

> Go for it.
> 
> —
> Reply to this email directly or view it on GitHubhttps://github.com/facebook/tornado/issues/651#issuecomment-11164216.

In general I prefer patches against master, unless it's for a bugfix that would be worth including in a 2.4.2 release.


Resolving patch:
diff --git a/tornado/iostream.py b/tornado/iostream.py
--- a/tornado/iostream.py
+++ b/tornado/iostream.py
@@ -209,10 +209,14 @@ def set_close_callback(self, callback):
         """Call the given callback when the stream is closed."""
         self._close_callback = stack_context.wrap(callback)
 
-    def close(self):
-        """Close this stream."""
+    def close(self, exc_info=False):
+        """Close this stream.
+
+        If `exc_info` is true, set the `error` attribute to the current
+        exception from ``sys.exc_info()``.
+        """
         if not self.closed():
-            if any(sys.exc_info()):
+            if exc_info and any(sys.exc_info()):
                 self.error = sys.exc_info()[1]
             if self._read_until_close:
                 callback = self._read_callback
@@ -285,7 +289,7 @@ def _handle_events(self, fd, events):
         except Exception:
             gen_log.error("Uncaught exception, closing connection.",
                           exc_info=True)
-            self.close()
+            self.close(exc_info=True)
             raise
 
     def _run_callback(self, callback, *args):
@@ -300,7 +304,7 @@ def wrapper():
                 # (It would eventually get closed when the socket object is
                 # gc'd, but we don't want to rely on gc happening before we
                 # run out of file descriptors)
-                self.close()
+                self.close(exc_info=True)
                 # Re-raise the exception so that IOLoop.handle_callback_exception
                 # can see it and log the error
                 raise
@@ -348,7 +352,7 @@ def _handle_read(self):
                 self._pending_callbacks -= 1
         except Exception:
             gen_log.warning("error on read", exc_info=True)
-            self.close()
+            self.close(exc_info=True)
             return
         if self._read_from_buffer():
             return
@@ -397,9 +401,9 @@ def _read_to_buffer(self):
                 # Treat ECONNRESET as a connection close rather than
                 # an error to minimize log spam  (the exception will
                 # be available on self.error for apps that care).
-                self.close()
+                self.close(exc_info=True)
                 return
-            self.close()
+            self.close(exc_info=True)
             raise
         if chunk is None:
             return 0
@@ -503,7 +507,7 @@ def _handle_write(self):
                 else:
                     gen_log.warning("Write error on %d: %s",
                                     self.fileno(), e)
-                    self.close()
+                    self.close(exc_info=True)
                     return
         if not self._write_buffer and self._write_callback:
             callback = self._write_callback
@@ -664,7 +668,7 @@ def connect(self, address, callback=None):
             if e.args[0] not in (errno.EINPROGRESS, errno.EWOULDBLOCK):
                 gen_log.warning("Connect error on fd %d: %s",
                                 self.socket.fileno(), e)
-                self.close()
+                self.close(exc_info=True)
                 return
         self._connect_callback = stack_context.wrap(callback)
         self._add_io_state(self.io_loop.WRITE)
@@ -733,7 +737,7 @@ def _do_ssl_handshake(self):
                 return
             elif err.args[0] in (ssl.SSL_ERROR_EOF,
                                  ssl.SSL_ERROR_ZERO_RETURN):
-                return self.close()
+                return self.close(exc_info=True)
             elif err.args[0] == ssl.SSL_ERROR_SSL:
                 try:
                     peer = self.socket.getpeername()
@@ -741,11 +745,11 @@ def _do_ssl_handshake(self):
                     peer = '(not connected)'
                 gen_log.warning("SSL Error on %d %s: %s",
                                 self.socket.fileno(), peer, err)
-                return self.close()
+                return self.close(exc_info=True)
             raise
         except socket.error, err:
             if err.args[0] in (errno.ECONNABORTED, errno.ECONNRESET):
-                return self.close()
+                return self.close(exc_info=True)
         else:
             self._ssl_accepting = False
             if self._ssl_connect_callback is not None:
@@ -842,7 +846,7 @@ def read_from_fd(self):
             elif e.args[0] == errno.EBADF:
                 # If the writing half of a pipe is closed, select will
                 # report it as readable but reads will fail with EBADF.
-                self.close()
+                self.close(exc_info=True)
                 return None
             else:
                 raise


Test patch:
diff --git a/tornado/test/iostream_test.py b/tornado/test/iostream_test.py
--- a/tornado/test/iostream_test.py
+++ b/tornado/test/iostream_test.py
@@ -2,7 +2,8 @@
 from tornado import netutil
 from tornado.ioloop import IOLoop
 from tornado.iostream import IOStream, SSLIOStream, PipeIOStream
-from tornado.log import gen_log
+from tornado.log import gen_log, app_log
+from tornado.stack_context import NullContext
 from tornado.testing import AsyncHTTPTestCase, AsyncHTTPSTestCase, AsyncTestCase, bind_unused_port, ExpectLog
 from tornado.test.util import unittest, skipIfNonUnix
 from tornado.util import b
@@ -197,6 +198,24 @@ def test_gaierror(self):
             stream.connect(('an invalid domain', 54321))
             self.assertTrue(isinstance(stream.error, socket.gaierror), stream.error)
 
+    def test_read_callback_error(self):
+        # Test that IOStream sets its exc_info when a read callback throws
+        server, client = self.make_iostream_pair()
+        try:
+            server.set_close_callback(self.stop)
+            with ExpectLog(
+                app_log, "(Uncaught exception|Exception in callback)"
+            ):
+                # Clear ExceptionStackContext so IOStream catches error
+                with NullContext():
+                    server.read_bytes(1, callback=lambda data: 1 / 0)
+                client.write(b("1"))
+                self.wait()
+            self.assertTrue(isinstance(server.error, ZeroDivisionError))
+        finally:
+            server.close()
+            client.close()
+
     def test_streaming_callback(self):
         server, client = self.make_iostream_pair()
         try: