Merge pull request #5907 from cjerdonek/broken-pipe-error

Handle BrokenPipeError gracefully (#4170)
This commit is contained in:
Pradyun Gedam 2019-01-21 16:37:46 +05:30 committed by GitHub
commit fc9bb60987
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 287 additions and 7 deletions

1
news/4170.bugfix Normal file
View File

@ -0,0 +1 @@
Handle a broken stdout pipe more gracefully (e.g. when running ``pip list | head``).

View File

@ -1,11 +1,12 @@
"""Base Command class, and related routines"""
from __future__ import absolute_import
from __future__ import absolute_import, print_function
import logging
import logging.config
import optparse
import os
import sys
import traceback
from pip._internal.cli import cmdoptions
from pip._internal.cli.parser import (
@ -27,7 +28,7 @@ from pip._internal.req.constructors import (
)
from pip._internal.req.req_file import parse_requirements
from pip._internal.utils.deprecation import deprecated
from pip._internal.utils.logging import setup_logging
from pip._internal.utils.logging import BrokenStdoutLoggingError, setup_logging
from pip._internal.utils.misc import (
get_prog, normalize_path, redact_password_from_url,
)
@ -191,6 +192,14 @@ class Command(object):
logger.critical('ERROR: %s', exc)
logger.debug('Exception information:', exc_info=True)
return ERROR
except BrokenStdoutLoggingError:
# Bypass our logger and write any remaining messages to stderr
# because stdout no longer works.
print('ERROR: Pipe to stdout was broken', file=sys.stderr)
if logger.getEffectiveLevel() <= logging.DEBUG:
traceback.print_exc(file=sys.stderr)
return ERROR
except KeyboardInterrupt:
logger.critical('Operation cancelled by user')

View File

@ -1,9 +1,13 @@
from __future__ import absolute_import
import contextlib
import errno
import logging
import logging.handlers
import os
import sys
from pip._vendor.six import PY2
from pip._internal.utils.compat import WINDOWS
from pip._internal.utils.misc import ensure_dir
@ -26,6 +30,48 @@ _log_state = threading.local()
_log_state.indentation = 0
class BrokenStdoutLoggingError(Exception):
"""
Raised if BrokenPipeError occurs for the stdout stream while logging.
"""
pass
# BrokenPipeError does not exist in Python 2 and, in addition, manifests
# differently in Windows and non-Windows.
if WINDOWS:
# In Windows, a broken pipe can show up as EINVAL rather than EPIPE:
# https://bugs.python.org/issue19612
# https://bugs.python.org/issue30418
if PY2:
def _is_broken_pipe_error(exc_class, exc):
"""See the docstring for non-Windows Python 3 below."""
return (exc_class is IOError and
exc.errno in (errno.EINVAL, errno.EPIPE))
else:
# In Windows, a broken pipe IOError became OSError in Python 3.
def _is_broken_pipe_error(exc_class, exc):
"""See the docstring for non-Windows Python 3 below."""
return ((exc_class is BrokenPipeError) or # noqa: F821
(exc_class is OSError and
exc.errno in (errno.EINVAL, errno.EPIPE)))
elif PY2:
def _is_broken_pipe_error(exc_class, exc):
"""See the docstring for non-Windows Python 3 below."""
return (exc_class is IOError and exc.errno == errno.EPIPE)
else:
# Then we are in the non-Windows Python 3 case.
def _is_broken_pipe_error(exc_class, exc):
"""
Return whether an exception is a broken pipe error.
Args:
exc_class: an exception class.
exc: an exception instance.
"""
return (exc_class is BrokenPipeError) # noqa: F821
@contextlib.contextmanager
def indent_log(num=2):
"""
@ -96,6 +142,16 @@ class ColorizedStreamHandler(logging.StreamHandler):
if WINDOWS and colorama:
self.stream = colorama.AnsiToWin32(self.stream)
def _using_stdout(self):
"""
Return whether the handler is using sys.stdout.
"""
if WINDOWS and colorama:
# Then self.stream is an AnsiToWin32 object.
return self.stream.wrapped is sys.stdout
return self.stream is sys.stdout
def should_color(self):
# Don't colorize things if we do not have colorama or if told not to
if not colorama or self._no_color:
@ -128,6 +184,19 @@ class ColorizedStreamHandler(logging.StreamHandler):
return msg
# The logging module says handleError() can be customized.
def handleError(self, record):
exc_class, exc = sys.exc_info()[:2]
# If a broken pipe occurred while calling write() or flush() on the
# stdout stream in logging's Handler.emit(), then raise our special
# exception so we can handle it in main() instead of logging the
# broken pipe error and continuing.
if (exc_class and self._using_stdout() and
_is_broken_pipe_error(exc_class, exc)):
raise BrokenStdoutLoggingError()
return super(ColorizedStreamHandler, self).handleError(record)
class BetterRotatingFileHandler(logging.handlers.RotatingFileHandler):

View File

@ -864,6 +864,13 @@ def captured_stdout():
return captured_output('stdout')
def captured_stderr():
"""
See captured_stdout().
"""
return captured_output('stderr')
class cached_property(object):
"""A property that is only computed once per instance and then replaces
itself with an ordinary attribute. Deleting the attribute resets the

View File

@ -0,0 +1,58 @@
import subprocess
import sys
if sys.version_info < (3, 6):
_BROKEN_STDOUT_RETURN_CODE = 1
else:
# The new exit status was added in Python 3.6 as a result of:
# https://bugs.python.org/issue5319
_BROKEN_STDOUT_RETURN_CODE = 120
def setup_broken_stdout_test(args, deprecated_python):
proc = subprocess.Popen(
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
# Call close() on stdout to cause a broken pipe.
proc.stdout.close()
returncode = proc.wait()
stderr = proc.stderr.read().decode('utf-8')
expected_msg = 'ERROR: Pipe to stdout was broken'
if deprecated_python:
assert expected_msg in stderr
else:
assert stderr.startswith(expected_msg)
return stderr, returncode
def test_broken_stdout_pipe(deprecated_python):
"""
Test a broken pipe to stdout.
"""
stderr, returncode = setup_broken_stdout_test(
['pip', 'list'], deprecated_python=deprecated_python,
)
# Check that no traceback occurs.
assert 'raise BrokenStdoutLoggingError()' not in stderr
assert stderr.count('Traceback') == 0
assert returncode == _BROKEN_STDOUT_RETURN_CODE
def test_broken_stdout_pipe__verbose(deprecated_python):
"""
Test a broken pipe to stdout with verbose logging enabled.
"""
stderr, returncode = setup_broken_stdout_test(
['pip', '-v', 'list'], deprecated_python=deprecated_python,
)
# Check that a traceback occurs and that it occurs at most once.
# We permit up to two because the exception can be chained.
assert 'raise BrokenStdoutLoggingError()' in stderr
assert 1 <= stderr.count('Traceback') <= 2
assert returncode == _BROKEN_STDOUT_RETURN_CODE

View File

@ -3,14 +3,19 @@ import os
import time
from pip._internal.cli.base_command import Command
from pip._internal.utils.logging import BrokenStdoutLoggingError
class FakeCommand(Command):
name = 'fake'
summary = name
def __init__(self, error=False):
self.error = error
def __init__(self, run_func=None, error=False):
if error:
def run_func():
raise SystemExit(1)
self.run_func = run_func
super(FakeCommand, self).__init__()
def main(self, args):
@ -19,8 +24,8 @@ class FakeCommand(Command):
def run(self, options, args):
logging.getLogger("pip.tests").info("fake")
if self.error:
raise SystemExit(1)
if self.run_func:
return self.run_func()
class FakeCommandWithUnicode(FakeCommand):
@ -34,6 +39,40 @@ class FakeCommandWithUnicode(FakeCommand):
)
class TestCommand(object):
def call_main(self, capsys, args):
"""
Call command.main(), and return the command's stderr.
"""
def raise_broken_stdout():
raise BrokenStdoutLoggingError()
cmd = FakeCommand(run_func=raise_broken_stdout)
status = cmd.main(args)
assert status == 1
stderr = capsys.readouterr().err
return stderr
def test_raise_broken_stdout(self, capsys):
"""
Test raising BrokenStdoutLoggingError.
"""
stderr = self.call_main(capsys, [])
assert stderr.rstrip() == 'ERROR: Pipe to stdout was broken'
def test_raise_broken_stdout__debug_logging(self, capsys):
"""
Test raising BrokenStdoutLoggingError with debug logging enabled.
"""
stderr = self.call_main(capsys, ['-v'])
assert 'ERROR: Pipe to stdout was broken' in stderr
assert 'Traceback (most recent call last):' in stderr
class Test_base_command_logging(object):
"""
Test `pip.base_command.Command` setting up logging consumers based on

View File

@ -1,8 +1,31 @@
import errno
import logging
import os
import time
from pip._internal.utils.logging import IndentingFormatter
import pytest
from mock import patch
from pip._vendor.six import PY2
from pip._internal.utils.logging import (
BrokenStdoutLoggingError, ColorizedStreamHandler, IndentingFormatter,
)
from pip._internal.utils.misc import captured_stderr, captured_stdout
logger = logging.getLogger(__name__)
# This is a Python 2/3 compatibility helper.
def _make_broken_pipe_error():
"""
Return an exception object representing a broken pipe.
"""
if PY2:
# This is one way a broken pipe error can show up in Python 2
# (a non-Windows example in this case).
return IOError(errno.EPIPE, 'Broken pipe')
return BrokenPipeError() # noqa: F821
class TestIndentingFormatter(object):
@ -43,3 +66,77 @@ class TestIndentingFormatter(object):
f = IndentingFormatter(fmt="%(message)s", add_timestamp=True)
expected = '2019-01-17T06:00:37 hello\n2019-01-17T06:00:37 world'
assert f.format(record) == expected
class TestColorizedStreamHandler(object):
def _make_log_record(self):
attrs = {
'msg': 'my error',
}
record = logging.makeLogRecord(attrs)
return record
def test_broken_pipe_in_stderr_flush(self):
"""
Test sys.stderr.flush() raising BrokenPipeError.
This error should _not_ trigger an error in the logging framework.
"""
record = self._make_log_record()
with captured_stderr() as stderr:
handler = ColorizedStreamHandler(stream=stderr)
with patch('sys.stderr.flush') as mock_flush:
mock_flush.side_effect = _make_broken_pipe_error()
# The emit() call raises no exception.
handler.emit(record)
err_text = stderr.getvalue()
assert err_text.startswith('my error')
# Check that the logging framework tried to log the exception.
if PY2:
assert 'IOError: [Errno 32] Broken pipe' in err_text
assert 'Logged from file' in err_text
else:
assert 'Logging error' in err_text
assert 'BrokenPipeError' in err_text
assert "Message: 'my error'" in err_text
def test_broken_pipe_in_stdout_write(self):
"""
Test sys.stdout.write() raising BrokenPipeError.
This error _should_ trigger an error in the logging framework.
"""
record = self._make_log_record()
with captured_stdout() as stdout:
handler = ColorizedStreamHandler(stream=stdout)
with patch('sys.stdout.write') as mock_write:
mock_write.side_effect = _make_broken_pipe_error()
with pytest.raises(BrokenStdoutLoggingError):
handler.emit(record)
def test_broken_pipe_in_stdout_flush(self):
"""
Test sys.stdout.flush() raising BrokenPipeError.
This error _should_ trigger an error in the logging framework.
"""
record = self._make_log_record()
with captured_stdout() as stdout:
handler = ColorizedStreamHandler(stream=stdout)
with patch('sys.stdout.flush') as mock_flush:
mock_flush.side_effect = _make_broken_pipe_error()
with pytest.raises(BrokenStdoutLoggingError):
handler.emit(record)
output = stdout.getvalue()
# Sanity check that the log record was written, since flush() happens
# after write().
assert output.startswith('my error')