Skip to content

Commit db97211

Browse files
gh-160309: Fix StreamReader.read() silently dropping incomplete final character
When a byte stream ends with an incomplete multibyte sequence (e.g. a truncated UTF-8 character), StreamReader.read() would silently discard those bytes: the read loop exited on empty newdata while bytebuffer still held the fragment, and bytebuffer was never decoded. Fix by detecting leftover bytes in bytebuffer when the underlying stream is exhausted and decoding them immediately. The codec's stateless decode function (e.g. utf_8_decode with final=True) then applies the error handler: 'strict' raises UnicodeDecodeError, 'replace' emits the replacement character, and 'ignore' drops the bytes - matching the behaviour of bytes.decode() and io.TextIOWrapper.
1 parent ffe1315 commit db97211

2 files changed

Lines changed: 29 additions & 0 deletions

File tree

‎Lib/codecs.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,19 @@ def read(self, size=-1, chars=-1, firstline=False):
525525
self.charbuffer += newchars
526526
# there was no data available
527527
if not newdata:
528+
# The stream is exhausted. If there are leftover bytes in
529+
# bytebuffer, they form an incomplete character. Decode them
530+
# now so that the error handler is applied (strict raises,
531+
# replace emits the replacement character, ignore drops them).
532+
if self.bytebuffer:
533+
try:
534+
newchars, decodedbytes = self.decode(
535+
self.bytebuffer, self.errors, True)
536+
except TypeError:
537+
newchars, decodedbytes = self.decode(
538+
self.bytebuffer, self.errors)
539+
self.bytebuffer = self.bytebuffer[decodedbytes:]
540+
self.charbuffer += newchars
528541
break
529542
if chars < 0:
530543
# Return everything we've got

‎Lib/test/test_codecs.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2103,6 +2103,22 @@ def test_pickle(self):
21032103
with self.assertRaisesRegex(TypeError, 'StreamReader'):
21042104
pickle.dumps(f, proto)
21052105

2106+
def test_read_incomplete_character(self):
2107+
# gh-160309: Incomplete multibyte sequence at the end of stream
2108+
# must not be silently dropped.
2109+
stream = io.BytesIO(b'ab\xe2')
2110+
f = self.reader(stream, errors='strict')
2111+
with self.assertRaises(UnicodeDecodeError):
2112+
f.read()
2113+
2114+
stream = io.BytesIO(b'ab\xe2')
2115+
f = self.reader(stream, errors='replace')
2116+
self.assertEqual(f.read(), 'ab\ufffd')
2117+
2118+
stream = io.BytesIO(b'ab\xe2')
2119+
f = self.reader(stream, errors='ignore')
2120+
self.assertEqual(f.read(), 'ab')
2121+
21062122

21072123
class StreamWriterTest(unittest.TestCase):
21082124

0 commit comments

Comments
 (0)