Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion cheroot/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,19 +68,27 @@ 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
errno.ECONNRESET, # corresponds to ConnectionResetError in Python 3
}
"""Errors that may happen during the connection close sequence.
Comment thread
julianz- marked this conversation as resolved.
Comment thread
julianz- marked this conversation as resolved.

* 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
Expand All @@ -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)
Comment thread
julianz- marked this conversation as resolved.

acceptable_sock_shutdown_exceptions = (
BrokenPipeError, # Covers EPIPE and ESHUTDOWN
ConnectionResetError, # Covers ECONNRESET
)
7 changes: 4 additions & 3 deletions cheroot/errors.pyi
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import typing as _t

class MaxSizeExceeded(Exception): ...
class NoSSLError(Exception): ...
class FatalSSLAlert(Exception): ...
Expand All @@ -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],
]
39 changes: 39 additions & 0 deletions cheroot/makefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -32,8 +34,45 @@
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

Check warning

Code scanning / CodeQL

Unreachable code Warning

This statement is unreachable.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests never hit this line. We should come up with a new regression test.


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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like tests never hit this.

except OSError as err:
# Handle EBADF and other acceptable socket shutdown errors
if err.errno in _errors.acceptable_sock_shutdown_error_codes:
return
Comment on lines +71 to +73

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@julianz- do you know if error codes listed there contain things that aren't represented by ConnectionError? I'm wondering if newer Python versions would ever hit this code branch — is it possible that the above ConnectionError will handle this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, do we know what's causing the tests to always hit this line?
https://app.codecov.io/gh/cherrypy/cheroot/pull/779?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=checks&utm_campaign=pr+comments&utm_term=cherrypy#d99a7689c2159eab73db39f9e5b89a1d-R70.

It's best to make sure that this isn't due to some mocks but a real simulated scenario.

raise

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wish we have proper logging infra here to see what can actually happen. The coverage report shows that this line is never exercised in the tests.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps, we could have a warning emitted, though..



class StreamReader(io.BufferedReader):
"""Socket stream reader."""
Expand Down
108 changes: 107 additions & 1 deletion cheroot/test/test_makefile.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
"""Tests for :py:mod:`cheroot.makefile`."""

import _pyio
import errno
import io

import pytest

from cheroot import makefile


class MockSocket:
"""A mock socket."""
"""A mock socket for emulating buffered I/O."""
Comment thread
julianz- marked this conversation as resolved.

def __init__(self):
"""Initialize :py:class:`MockSocket`."""
Expand Down Expand Up @@ -35,6 +41,31 @@ def _decref_socketios(self):
# Ref: https://github.com/cherrypy/cheroot/issues/734


class MockRawIO:
"""A mock ``io.RawIOBase`` object for use as the raw layer of a ``BufferedWriter``."""

def __init__(self):
"""Initialize :py:class:`MockRawIO`."""
self._is_closed = False

def write(self, message):
"""Emulate ``io.RawIOBase write``."""
return len(message)

def writable(self):
"""Indicate that the raw stream supports writing."""
return True

def close(self):
"""Emulate close."""
self._is_closed = True

@property
def closed(self):
"""Emulate the required ``closed`` property."""
return self._is_closed


def test_bytes_read():
"""Reader should capture bytes read."""
sock = MockSocket()
Expand All @@ -51,3 +82,78 @@ def test_bytes_written():
wfile = makefile.MakeFile(sock, 'w')
wfile.write(b'bar')
assert wfile.bytes_written == 3


def test_close_is_idempotent():
"""Test that double ``close()`` does not error out."""
raw_buffer = io.BytesIO()
buffered_writer = makefile.BufferedWriter(raw_buffer)

buffered_writer.close()
assert buffered_writer.closed

buffered_writer.close()
assert buffered_writer.closed
Comment thread
julianz- marked this conversation as resolved.


def test_close_when_raw_already_closed():
"""Test that ``close()`` is safe when the raw buffer was closed externally.

Simulates a race where the OS or another thread closed the raw socket
before ``BufferedWriter.close()`` is called.
"""
raw_buffer = io.BytesIO()
buffered_writer = makefile.BufferedWriter(raw_buffer)

raw_buffer.close()
Comment thread
julianz- marked this conversation as resolved.
assert buffered_writer.closed # property reflects raw state

buffered_writer.close()


@pytest.mark.parametrize(
('exc', 'reraises'),
(
(BrokenPipeError(errno.EPIPE, 'Broken pipe'), False),
(OSError(errno.EBADF, 'Bad file descriptor'), False),
(OSError(errno.EIO, 'I/O error'), True),
),
ids=['broken_pipe', 'ebadf', 'unexpected_oserror'],
)
def test_close_error_handling(exc, reraises, mocker):
"""Test that expected socket errors are swallowed and others re-raised."""
writer = makefile.BufferedWriter(MockRawIO())
mocker.patch.object(_pyio.BufferedWriter, 'close', side_effect=exc)
if reraises:
with pytest.raises(OSError, match='I/O error'):
writer.close()
else:
writer.close()


def _make_blocking_io_error():
"""Create a :exc:`BlockingIOError` with ``characters_written`` set."""
err = io.BlockingIOError(errno.EAGAIN, 'Resource temporarily unavailable')
err.characters_written = 5
return err


@pytest.mark.parametrize(
('write_kwargs', 'expected_buf_len'),
(
({'return_value': 0}, len(b'data to flush')),
({'return_value': None}, len(b'data to flush')),
({'side_effect': _make_blocking_io_error()}, 0),
),
ids=['zero_return', 'none_return', 'blocking_io_error'],
)
def test_flush_unlocked_write_outcomes(write_kwargs, expected_buf_len, mocker):
"""Test that ``_flush_unlocked`` handles various ``write()`` outcomes."""
data = b'data to flush'
writer = makefile.BufferedWriter(MockRawIO())
writer._write_buf = bytearray(data)
mocker.patch.object(writer.raw, 'write', **write_kwargs)

writer._flush_unlocked()

assert len(writer._write_buf) == expected_buf_len
4 changes: 4 additions & 0 deletions docs/changelog-fragments.d/779.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Socket I/O is now resilient to race conditions happening during connection teardown
due to sockets dying independently or being closed externally.

-- by :user:`julianz-`
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Comment thread
julianz- marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ subpackages
symlinked
syscall
systemd
teardown
threadpool
Tidelift
TLS
Expand Down
Loading