diff --git a/.flake8 b/.flake8 index 6ab1219ead..b5eec11702 100644 --- a/.flake8 +++ b/.flake8 @@ -138,7 +138,7 @@ per-file-ignores = cheroot/test/conftest.py: DAR101, DAR201, DAR301, I001, I003, I005, WPS100, WPS130, WPS325, WPS354, WPS420, WPS422, WPS430, WPS457 cheroot/test/helper.py: DAR101, DAR201, DAR401, I001, I003, I004, N802, WPS110, WPS111, WPS121, WPS201, WPS220, WPS231, WPS301, WPS414, WPS421, WPS422, WPS505 cheroot/test/test_cli.py: DAR101, DAR201, I001, I005, N802, S101, S108, WPS110, WPS421, WPS431, WPS473 - cheroot/test/test_makefile.py: DAR101, DAR201, I004, RST304, S101, WPS110, WPS122 + cheroot/test/test_makefile.py: DAR101, DAR201, DAR401, I004, RST304, S101, WPS110, WPS122, WPS202 cheroot/test/test_wsgi.py: DAR101, DAR301, I001, I004, S101, WPS110, WPS111, WPS117, WPS118, WPS121, WPS210, WPS421, WPS430, WPS432, WPS441, WPS509 cheroot/test/test_core.py: C815, DAR101, DAR201, DAR401, I003, I004, N805, N806, S101, WPS110, WPS111, WPS114, WPS121, WPS202, WPS204, WPS226, WPS229, WPS324, WPS421, WPS422, WPS432, WPS602 cheroot/test/test_dispatch.py: DAR101, DAR201, S101, WPS111, WPS121, WPS422, WPS430 diff --git a/cheroot/errors.py b/cheroot/errors.py index a1103595c2..a65e641aa8 100644 --- a/cheroot/errors.py +++ b/cheroot/errors.py @@ -3,6 +3,8 @@ import errno import sys +from . import _compat + class MaxSizeExceeded(Exception): """Exception raised when a client sends more data then allowed under limit. @@ -66,6 +68,10 @@ def plat_specific_errors(*errnames): acceptable_sock_shutdown_error_codes = { + errno.EBADF, # operating on a closed/invalid file descriptor + *( + (errno.WSAENOTSOCK,) if _compat.IS_WINDOWS else () + ), # Windows equivalent of EBADF errno.ENOTCONN, errno.EPIPE, errno.ESHUTDOWN, # corresponds to BrokenPipeError in Python 3 @@ -73,12 +79,16 @@ def plat_specific_errors(*errnames): } """Errors that may happen during the connection close sequence. +* EBADF — operating on a closed or invalid file descriptor +* WSAENOTSOCK — Windows equivalent of EBADF; raised when the socket has + already been closed by the OS or another thread * ENOTCONN — client is no longer connected * EPIPE — write on a pipe while the other end has been closed * ESHUTDOWN — write on a socket which has been shutdown for writing * ECONNRESET — connection is reset by the peer, we received a TCP RST packet Refs: + * https://github.com/cherrypy/cheroot/issues/341#issuecomment-735884889 * https://bugs.python.org/issue30319 * https://bugs.python.org/issue30329 @@ -87,4 +97,8 @@ def plat_specific_errors(*errnames): * https://docs.microsoft.com/windows/win32/api/winsock/nf-winsock-shutdown """ -acceptable_sock_shutdown_exceptions = (BrokenPipeError, ConnectionResetError) + +acceptable_sock_shutdown_exceptions = ( + BrokenPipeError, # Covers EPIPE and ESHUTDOWN + ConnectionResetError, # Covers ECONNRESET +) diff --git a/cheroot/errors.pyi b/cheroot/errors.pyi index dd60f7a4a4..e5fc96a97c 100644 --- a/cheroot/errors.pyi +++ b/cheroot/errors.pyi @@ -1,5 +1,3 @@ -import typing as _t - class MaxSizeExceeded(Exception): ... class NoSSLError(Exception): ... class FatalSSLAlert(Exception): ... @@ -10,4 +8,7 @@ socket_error_eintr: list[int] socket_errors_to_ignore: list[int] socket_errors_nonblocking: list[int] acceptable_sock_shutdown_error_codes: set[int] -acceptable_sock_shutdown_exceptions: tuple[_t.Type[Exception], ...] +acceptable_sock_shutdown_exceptions: tuple[ + type[BrokenPipeError], + type[ConnectionResetError], +] diff --git a/cheroot/makefile.py b/cheroot/makefile.py index f5780a1ede..d3d40682de 100644 --- a/cheroot/makefile.py +++ b/cheroot/makefile.py @@ -4,6 +4,8 @@ import _pyio as io import socket +from . import errors as _errors + # Write only 16K at a time to sockets SOCK_WRITE_BLOCKSIZE = 16384 @@ -32,8 +34,45 @@ def _flush_unlocked(self): n = self.raw.write(bytes(self._write_buf)) except io.BlockingIOError as e: n = e.characters_written + + if n == 0: + # Non-blocking socket can't write right now — stop flushing. + break + if not n: + # Defensive: write() returned None or other falsy value, + # which shouldn't happen but could cause an infinite loop. + break + del self._write_buf[:n] + def close(self): + """ + Close the stream and its underlying file object. + + This method is designed to be idempotent (it can be called multiple + times without side effects). It gracefully handles a race condition + where the underlying socket may have already been closed by the remote + client or another thread. + + A :exc:`ConnectionError` or :exc:`OSError` with + :data:`~errno.EBADF` or :data:`~errno.ENOTCONN` is caught + and ignored, as these indicate a normal, expected connection teardown. + Other exceptions are re-raised. + """ + # pylint incorrectly flags inherited self.closed property as constant + if self.closed: # pylint: disable=using-constant-test + return + + try: + super().close() + except ConnectionError: + return + except OSError as err: + # Handle EBADF and other acceptable socket shutdown errors + if err.errno in _errors.acceptable_sock_shutdown_error_codes: + return + raise + class StreamReader(io.BufferedReader): """Socket stream reader.""" diff --git a/cheroot/test/_pytest_plugin.py b/cheroot/test/_pytest_plugin.py index 51d22a0a05..e31bd7a72a 100644 --- a/cheroot/test/_pytest_plugin.py +++ b/cheroot/test/_pytest_plugin.py @@ -25,21 +25,5 @@ def pytest_load_initial_conftests(early_config, parser, args): 'pytest.PytestUnhandledThreadExceptionWarning:_pytest.threadexception', 'ignore:Exception in thread Thread-:' 'pytest.PytestUnhandledThreadExceptionWarning:_pytest.threadexception', - 'ignore:Exception ignored in. ' - '