diff --git a/cheroot/makefile.py b/cheroot/makefile.py index f5780a1ede..0376655548 100644 --- a/cheroot/makefile.py +++ b/cheroot/makefile.py @@ -26,13 +26,17 @@ def write(self, b): def _flush_unlocked(self): self._checkClosed('flush of closed file') while self._write_buf: + n = None try: # ssl sockets only except 'bytes', not bytearrays # so perhaps we should conditionally wrap this for perf? - n = self.raw.write(bytes(self._write_buf)) + n = self.raw.write( + bytes(self._write_buf[:SOCK_WRITE_BLOCKSIZE]), + ) except io.BlockingIOError as e: n = e.characters_written - del self._write_buf[:n] + if n: + del self._write_buf[:n] class StreamReader(io.BufferedReader): diff --git a/cheroot/test/test_makefile.py b/cheroot/test/test_makefile.py index d65d4ea268..6a65dd574e 100644 --- a/cheroot/test/test_makefile.py +++ b/cheroot/test/test_makefile.py @@ -51,3 +51,46 @@ def test_bytes_written(): wfile = makefile.MakeFile(sock, 'w') wfile.write(b'bar') assert wfile.bytes_written == 3 + + +class _RawWriteBlockOnce: + """Mock raw.write() that returns None on the first call, then writes normally.""" + + def __init__(self): + """Initialize _RawWriteBlockOnce.""" + self.call_count = 0 + self.written = bytearray() + + def __call__(self, chunk): + """Return None on first call to simulate a blocked socket write.""" + self.call_count += 1 + if self.call_count == 1: + return ( + None # simulates socket returning None on first blocked write + ) + self.written.extend(chunk) + return len(chunk) + + +def test_flush_when_raw_write_returns_none(): + """_flush_unlocked() must not treat None from raw.write() as a byte count. + + io.RawIOBase.write() returns None when a non-blocking socket cannot accept + data. del self._write_buf[:None] is equivalent to del self._write_buf[:] + which silently clears the entire buffer, truncating the response without + raising an exception. + """ + data = b'x' * (makefile.SOCK_WRITE_BLOCKSIZE * 2) # stress the write loop + + sock = MockSocket() + wfile = makefile.MakeFile(sock, 'w') + wfile._write_buf.extend(data) + + mock = _RawWriteBlockOnce() + wfile.raw.write = mock + wfile._flush_unlocked() + + assert bytes(mock.written) == data, ( + f'Expected {len(data)} bytes but only {len(mock.written)} reached raw.write(): ' + 'buffer was silently discarded when raw.write() returned None' + ) diff --git a/docs/changelog-fragments.d/822.bugfix.rst b/docs/changelog-fragments.d/822.bugfix.rst new file mode 100644 index 0000000000..97493eeb59 --- /dev/null +++ b/docs/changelog-fragments.d/822.bugfix.rst @@ -0,0 +1,3 @@ +Fixed a bug that could cause premature clearing of the write buffer when a socket write is blocked. + +-- by :user:`cbbm142` diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 06b204518e..981a6b3f97 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -5,6 +5,7 @@ backports bugfixes builtin b'xb +buf compat config conftest @@ -22,6 +23,7 @@ hardcoded hostname inclusivity intersphinx +io iterable linter linters @@ -48,6 +50,7 @@ preconfigure py pytest pythonic +RawIOBase readonly rebase Refactor