From 0855c8ace4d95d8c5170b4f1d4d7fd3354687e95 Mon Sep 17 00:00:00 2001 From: John Pastore <50429657+cbbm142@users.noreply.github.com> Date: Tue, 19 May 2026 11:46:29 -0400 Subject: [PATCH 1/4] Preserve write buffer when raw.write() returns None on blocked socket Add a check in cheroot.makefile.BufferedWriter._flush_unlocked to prevent clearing of the write buffer when raw.write() returns None due to a blocked stream. Also limits each write call to SOCK_WRITE_BLOCKSIZE bytes, which was defined but not previously used in this method. --- cheroot/makefile.py | 8 ++++++-- cheroot/test/test_makefile.py | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) 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..7aea33860a 100644 --- a/cheroot/test/test_makefile.py +++ b/cheroot/test/test_makefile.py @@ -51,3 +51,39 @@ def test_bytes_written(): wfile = makefile.MakeFile(sock, 'w') wfile.write(b'bar') assert wfile.bytes_written == 3 + + +def test_flush_preserves_data_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' * 32768 # larger than SOCK_WRITE_BLOCKSIZE to stress the loop + + sock = MockSocket() + wfile = makefile.MakeFile(sock, 'w') + wfile._write_buf.extend(data) + + written = bytearray() + call_count = 0 + + def mock_raw_write(b): + nonlocal call_count + call_count += 1 + if call_count == 1: + return ( + None # simulates socket returning None on first blocked write + ) + written.extend(b) + return len(b) + + wfile.raw.write = mock_raw_write + wfile._flush_unlocked() + + assert bytes(written) == data, ( + f'Expected {len(data)} bytes but only {len(written)} reached raw.write(): ' + 'buffer was silently discarded when raw.write() returned None' + ) From 844bc5173cd1f048158feae851f3d016d0e26db3 Mon Sep 17 00:00:00 2001 From: John Pastore <50429657+cbbm142@users.noreply.github.com> Date: Tue, 19 May 2026 11:46:29 -0400 Subject: [PATCH 2/4] Fix linting issues in makefile test Updated test to allow Flake8/pre-commit checks to pass successfully. --- cheroot/test/test_makefile.py | 43 ++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/cheroot/test/test_makefile.py b/cheroot/test/test_makefile.py index 7aea33860a..6a65dd574e 100644 --- a/cheroot/test/test_makefile.py +++ b/cheroot/test/test_makefile.py @@ -53,7 +53,26 @@ def test_bytes_written(): assert wfile.bytes_written == 3 -def test_flush_preserves_data_when_raw_write_returns_none(): +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 @@ -61,29 +80,17 @@ def test_flush_preserves_data_when_raw_write_returns_none(): which silently clears the entire buffer, truncating the response without raising an exception. """ - data = b'x' * 32768 # larger than SOCK_WRITE_BLOCKSIZE to stress the loop + data = b'x' * (makefile.SOCK_WRITE_BLOCKSIZE * 2) # stress the write loop sock = MockSocket() wfile = makefile.MakeFile(sock, 'w') wfile._write_buf.extend(data) - written = bytearray() - call_count = 0 - - def mock_raw_write(b): - nonlocal call_count - call_count += 1 - if call_count == 1: - return ( - None # simulates socket returning None on first blocked write - ) - written.extend(b) - return len(b) - - wfile.raw.write = mock_raw_write + mock = _RawWriteBlockOnce() + wfile.raw.write = mock wfile._flush_unlocked() - assert bytes(written) == data, ( - f'Expected {len(data)} bytes but only {len(written)} reached raw.write(): ' + 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' ) From 5eaf1acbdf4f9ad1b8ec24df47882dfff25d809d Mon Sep 17 00:00:00 2001 From: John Pastore <50429657+cbbm142@users.noreply.github.com> Date: Tue, 19 May 2026 14:20:15 -0400 Subject: [PATCH 3/4] Fix spell check failures Updated allowlist to include buf, io, and RawIOBase --- docs/spelling_wordlist.txt | 3 +++ 1 file changed, 3 insertions(+) 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 From d3e8258f38135ebfa20ada365343676fc381ad62 Mon Sep 17 00:00:00 2001 From: John Pastore <50429657+cbbm142@users.noreply.github.com> Date: Wed, 20 May 2026 07:08:27 -0400 Subject: [PATCH 4/4] Add file for towncrier Added changelog entry for change #822 --- docs/changelog-fragments.d/822.bugfix.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/changelog-fragments.d/822.bugfix.rst 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`