gh-157242: Fix PyBytesWriter_Resize() on MemoryError - #157243
Conversation
If bytearray.resize() or bytearray.take_bytes() fails, leave the bytearray unchanged. If PyBytesWriter_Resize() fails, leave the writer unchanged. Add a new internal _PyBytes_ResizeKeepOnError() function similar to _PyBytes_Resize() but leaves the bytes object unchanged on error.
|
It would be nice if the "guaranteed no global" case was also used for the |
serhiy-storchaka
left a comment
There was a problem hiding this comment.
No need to add a new parameter, it saves nothing. _PyBytes_Resize can be implemented via _PyBytes_ResizeKeepOnError.
I wonder if we can simply change the behavior of _PyBytes_Resize.
Co-authored-by: Maurycy Pawłowski-Wieroński <maurycy@maurycy.com>
For the in-place resize code path, no longer call _Py_ForgetReference() and _PyReftracerTrack() before PyObject_Realloc().
|
Please review the updated PR. I addressed reviews. @maurycy found a fix for the memmove() code path which worried me. I applied his suggestion and added a test.
Thanks for the advice. I reworked _PyBytes_Resize(): for the in-place resize code path, no longer call _Py_ForgetReference() and _PyReftracerTrack() before PyObject_Realloc(). Only call them on success. With this change, I was able to easy implement _PyBytes_Resize() with _PyBytes_ResizeKeepOnError(). |
Maybe Note: PR gh-156996 does fix PyBytes_FromStringAndSize() usage in bytearray. I don't try to replace this fix. |
|
bytearray.resize() and bytearray.take_bytes() have been fixed to no longer use a singleton: I merged main in my PR to get the PR gh-156996 fix. |
|
@maurycy: I modified resize() and take_bytes() to avoid memmove() usage if we would be unable to revert the bytesarray to its previous state on MemoryError. Does it look correct to you? I also added more tests injecting MemoryError. |
|
@vstinner: Thank you. I will take a look more carefully later today. |
|
@vstinner Two more findings. That's such a nice PR! Good news: the PR fixes this regression: 2026-09-10T17:44:57.890346000+0200 maurycy@gimel /Users/maurycy/work/cpython (main 9bd670c?) % ./python.exe
Python 3.16.0a0 (heads/main:9bd670cba21, Sep 10 2026, 17:37:43) [Clang 21.0.0 (clang-2100.1.1.101)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import _testcapi
... import sys
...
... ba = bytearray(b"abc123")
... try:
... _testcapi.set_nomemory(0)
... del ba[1:-1]
... except MemoryError: pass
... finally: _testcapi.remove_mem_hooks()
...
>>> len(ba)
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
len(ba)
~~~^^^^
SystemError: <built-in function len> returned NULL without setting an exception
>>> bytes(ba)
Traceback (most recent call last):
File "<python-input-2>", line 1, in <module>
bytes(ba)
~~~~~^^^^
ValueError: size must be >= 0
>>>
2026-09-10T17:45:16.126365000+0200 maurycy@gimel /Users/maurycy/work/cpython-pr157243 (pr157243 f51cba5?) % ./python.exe
Python 3.16.0a0 (heads/pr157243:f51cba57d3e, Sep 10 2026, 14:31:04) [Clang 21.0.0 (clang-2100.1.1.101)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import _testcapi
... import sys
...
... ba = bytearray(b"abc123")
... try:
... _testcapi.set_nomemory(0)
... del ba[1:-1]
... except MemoryError: pass
... finally: _testcapi.remove_mem_hooks()
...
>>> len(ba)
2
>>> bytes(ba)
b'a3'
>>> Perhaps we cover this with test, too? Bad news: 2026-09-10T17:47:19.394053000+0200 maurycy@gimel /Users/maurycy/work/cpython-pr157243 (pr157243 f51cba5?) % ./python.exe
Python 3.16.0a0 (heads/pr157243:f51cba57d3e, Sep 10 2026, 14:31:04) [Clang 21.0.0 (clang-2100.1.1.101)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import _testcapi
... import sys
...
... ba = bytearray(b"abc123456")
... ba += b"7"
...
... try:
... _testcapi.set_nomemory(0)
... del ba[::2]
... except MemoryError: pass
... finally: _testcapi.remove_mem_hooks()
...
... ba
...
bytearray(b'b135734567')
>>>
2026-09-10T17:48:34.225959000+0200 maurycy@gimel /Users/maurycy/work/cpython (main 9bd670c?) % ./python.exe
Python 3.16.0a0 (heads/main:9bd670cba21, Sep 10 2026, 17:37:43) [Clang 21.0.0 (clang-2100.1.1.101)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import _testcapi
... import sys
...
... ba = bytearray(b"abc123456")
... ba += b"7"
...
... try:
... _testcapi.set_nomemory(0)
... del ba[::2]
... except MemoryError: pass
... finally: _testcapi.remove_mem_hooks()
...
... ba
...
bytearray(b'')Unfortunately, I don't have bandwitch to investigate what's the exact fix here |
alloc is just the ob_bytes_object size.
|
@maurycy @cmaloney: Oh, you made multiple comments. Let me push a first batch of fixes:
I checked tracemalloc "ref tracer" callback: it does read the object memory, so the pointer must not be a dangling pointer or Python will crash. I reverted my change: |
|
@vstinner Thanks, I appreciate it! Could you re-request a review once you've pushed the remaining fixes? |
|
@maurycy @cmaloney: I finished to update the PR, you can now review it. I pushed a fix to write the null byte on I'm not sure that test_bytes_resize_tracer() is needed: it's a functional test on PyRefTracer_DESTROY and PyRefTracer_CREATE events. I wrote it to make sure that we don't mess up with these events if the code is modified later. I also wrote a large change locally to check bytearray consistency in all methods modifying bytearray, but I prefer to propose a separated PR later for this change. For example, my check makes sure that the bytearray has trailing null byte. See also PR gh-156943 which uses a "canary byte" to detect buffer overflow in the PyBytesWriter C API. |
| static void | ||
| bytearray_reinit_from_bytes(PyByteArrayObject *self, Py_ssize_t size, | ||
| Py_ssize_t alloc) | ||
| bytearray_reinit_from_bytes(PyByteArrayObject *self, Py_ssize_t size) |
There was a problem hiding this comment.
Removing the alloc parameter is not strictly needed by this PR, but I removed it to help me checking that the bytearray remains consistent with my changes.
I'm not sure that Python 3.14 and older leaves the bytearray unchanged on MemoryError. For example, /* memmove() removed bytes, the bytearray object cannot be
restored in its previous state. */ |
Always use _PyBytes_ResizeKeepOnError(), but move remaining bytes on error.
Rename bytearray_realign_data_lock_held() to bytearray_resize_storage(). On error, only move data if ob_start was different than ob_bytes. Add one more test: resize() shrinks without logical offset.
|
@vstinner You're right. Both `git bisect log` (`v3.14.0..main`, `Objects/bytearrayobject.c`)2026-09-11T22:58:48.213303000+0200 maurycy@gimel /Users/maurycy/src/github.com/maurycy/cpython (main 392ad7e?) % cat repro.py
import _testcapi
ba = bytearray(b"abc123")
try:
_testcapi.set_nomemory(0); del ba[1:-1]
except MemoryError: pass
finally: _testcapi.remove_mem_hooks()
assert len(ba) == 2, ba
2026-09-11T22:58:48.905587000+0200 maurycy@gimel /Users/maurycy/src/github.com/maurycy/cpython (main 392ad7e?) % git bisect start upstream-ro/main v3.14.0 -- Objects/bytearrayobject.c
Bisecting: a merge base must be tested
[b092705907c758d4f9742028652c9802f9f03dd3] Python 3.14.0b1
maurycy@gimel cpython (git)-[tags/v3.14.0b1|bisect]?- % git bisect run sh -c 'make -s -j8 >/dev/null 2>&1 || exit 125; ./python.exe repro.py; test $? -eq 0'
running 'sh' '-c' 'make -s -j8 >/dev/null 2>&1 || exit 125; ./python.exe repro.py; test $? -eq 0'
Bisecting: 9 revisions left to test after this (roughly 3 steps)
[9976c2b6349a079ae39931d960b8c147e21c6c3f] gh-143195: fix UAF in `{bytearray,memoryview}.hex(sep)` via re-entrant `sep.__len__` (#143209)
running 'sh' '-c' 'make -s -j8 >/dev/null 2>&1 || exit 125; ./python.exe repro.py; test $? -eq 0'
Traceback (most recent call last):
File "/Users/maurycy/src/github.com/maurycy/cpython/repro.py", line 7, in <module>
assert len(ba) == 2, ba
~~~^^^^
SystemError: <built-in function len> returned NULL without setting an exception
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[6416e6ebe5b88087ada6f4a56972053edb9c2e01] gh-129559: Remove extra dot in bytearray.resize AC (#140134)
running 'sh' '-c' 'make -s -j8 >/dev/null 2>&1 || exit 125; ./python.exe repro.py; test $? -eq 0'
Bisecting: 2 revisions left to test after this (roughly 1 step)
[e265ce8a563ba7f91c5ada0592de8cb85622b433] gh-139871: Optimize small takes in bytearray.take_bytes (GH-141741)
running 'sh' '-c' 'make -s -j8 >/dev/null 2>&1 || exit 125; ./python.exe repro.py; test $? -eq 0'
Traceback (most recent call last):
File "/Users/maurycy/src/github.com/maurycy/cpython/repro.py", line 7, in <module>
assert len(ba) == 2, ba
~~~^^^^
SystemError: <built-in function len> returned NULL without setting an exception
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[732224e1139f7ed4fe0259a2dad900f84910949e] gh-139871: Add `bytearray.take_bytes([n])` to efficiently extract `bytes` (GH-140128)
running 'sh' '-c' 'make -s -j8 >/dev/null 2>&1 || exit 125; ./python.exe repro.py; test $? -eq 0'
Traceback (most recent call last):
File "/Users/maurycy/src/github.com/maurycy/cpython/repro.py", line 7, in <module>
assert len(ba) == 2, ba
~~~^^^^
SystemError: <built-in function len> returned NULL without setting an exception
732224e1139f7ed4fe0259a2dad900f84910949e is the first 'bad' commit
commit 732224e1139f7ed4fe0259a2dad900f84910949e
Author: Cody Maloney <cmaloney@users.noreply.github.com>
Date: 2025-11-13 05:19:44 -0800
gh-139871: Add `bytearray.take_bytes([n])` to efficiently extract `bytes` (GH-140128)
Update `bytearray` to contain a `bytes` and provide a zero-copy path to
"extract" the `bytes`. This allows making several code paths more efficient.
This does not move any codepaths to make use of this new API. The documentation
changes include common code patterns which can be made more efficient with
this API.
---
When just changing `bytearray` to contain `bytes` I ran pyperformance on a
`--with-lto --enable-optimizations --with-static-libpython` build and don't see
any major speedups or slowdowns with this; all seems to be in the noise of
my machine (Generally changes under 5% or benchmarks that don't touch
bytes/bytearray).
Co-authored-by: Victor Stinner <vstinner@python.org>
Co-authored-by: Maurycy Pawłowski-Wieroński <5383+maurycy@users.noreply.github.com>
Doc/library/stdtypes.rst | 24 +++
Doc/whatsnew/3.15.rst | 80 +++++++
Include/cpython/bytearrayobject.h | 16 +-
Include/internal/pycore_bytesobject.h | 8 +
Lib/test/test_bytes.py | 81 +++++++
Lib/test/test_capi/test_bytearray.py | 5 +-
Lib/test/test_sys.py | 2 +-
.../2025-10-14-18-24-16.gh-issue-139871.SWtuUz.rst | 2 +
Objects/bytearrayobject.c | 238 ++++++++++++++-------
Objects/bytesobject.c | 8 +-
Objects/clinic/bytearrayobject.c.h | 39 +++-
11 files changed, 407 insertions(+), 96 deletions(-)
create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-10-14-18-24-16.gh-issue-139871.SWtuUz.rst
bisect found first 'bad' commit
maurycy@gimel cpython (git)-[bisect/bad|bisect]?- % I cannot approve but it LGreatTM. |
|
The PR became quite big, so I modified it to focus strictly on |
|
Ok, I merged the first part. I created the follow-up PR for bytearray: PR gh-157340. |
If PyBytesWriter_Resize() fails, leave the writer unchanged.
Add a new internal _PyBytes_ResizeKeepOnError() function similar to _PyBytes_Resize() but leaves the bytes object unchanged on error.