gh-153419: Fix several issues around bytearray __init__#153498
Conversation
Introduce a bytearray_new function to ensure that
ob_bytes_object is always set on a bytearray.
Resizing a bytearray to 0 length explicitly sets
the ob_bytes_object to the empty constant immortal.
Add a check in the 'bytearray init from string'
fast path to ensure there are no active exports.
This fixes asserts/crashes on the following:
- bytearray(1).__init__()
- bytearray().__new__(bytearray).append(1)
- a = bytearray(); b = memoryview(a); a.__init__('x', 'ascii')
This reverts commit c961d61.
There are several ways that bytearray_resize_lock_held gets called and for any of them, ob_bytes_object may be NULL. This allows us to remove the extra initialization in __init__ as this can't be guaranteed to be called anyway. Handle a related issue in take_bytes where in the extreme case of the bytes resize allocation failing (on a size reduction) then the bytearray other state fileds could be left inconsistent with a null ob_bytes_object. unconditionally call _canresize in __init__ as on initial creation, this will always be true, and if there are any exported views, then we just always fail, even if technically an empty bytearray doesn't get modified in this case, it's so rare, it's not worth it.
|
OK, rewritten based on @cmaloney's comments on the issue. Some of the changes are a bit non-obvious, for example initializing As it now intentionally tolerates uninitialized We have to check There may be some other |
Format blurb correctly, and reduce the chatter (There is a user-visible behaviour change, but I guess it's so minor it's not worth mentioning)
|
@cmaloney: are you able to take another look? Sorry for bugging you! |
| /* If ob_bytes_object has not been initialized yet, eagerly initialize | ||
| it here so the following code can reason about state more easily, | ||
| and things like pointer comparisons are valid. */ | ||
| if (obj->ob_bytes_object == NULL) { |
There was a problem hiding this comment.
Reading through https://docs.python.org/3/extending/newtypes_tutorial.html a bit it feels like should make it so tp_new does the "ob_bytes_object" is always NULL, then other places either get an allocation or assign to the empty bytes.
I think that will reduce the amount of code a bit and make it easier to get right all the corner cases.
cmaloney
left a comment
There was a problem hiding this comment.
Generally looking good, like the shape. Some comments and suggestions.
| @@ -0,0 +1 @@ | |||
| Fix several :class:`bytearray` crashes caused by calling :meth:`~object.__init__`/:meth:`~object.__new__` in unusual ways. | |||
There was a problem hiding this comment.
| Fix several :class:`bytearray` crashes caused by calling :meth:`~object.__init__`/:meth:`~object.__new__` in unusual ways. | |
| Fix multiple :class:`bytearray` crashes and reference leaks caused by skipping :meth:`~object.__init__` and broken state setup code. |
| self.assertRaises(BufferError, ba.hex, S(b':')) | ||
|
|
||
| def test_uninitialized_instance(self): | ||
| # A bytearray created with __new__ so that __init__ is never called |
There was a problem hiding this comment.
This is fairly verbose at the moment, now that there is a new I think can condense a bit
|
@cmaloney I've updated the PR with the review comments, the ob_exports is technically a no-op because PyType_GenericNew calls PyType_GenericAlloc which zeroes the fields, but it's trivial safe and simple to add, so I did. Thanks! |
cmaloney
left a comment
There was a problem hiding this comment.
Two small comment nits, think this is ready for other core review.
| bytearray_reinit_from_bytes(self, 0, 0); | ||
| self->ob_exports = 0; | ||
| /* Disallow any __init__ call if the object is not resizable (has exports) | ||
| to make the handling of non-null `source` init values simpler. */ |
There was a problem hiding this comment.
I don't think this quite matches the current state? The non-null is always handled in the tp_new; the resize is just preventing a reallocation which would leave the existing exports and pointers into unowned memory
There was a problem hiding this comment.
This was probably a badly worded comment, as per your previous, the _canresize() isn't strictly needed here, because PyByteArray_Resize checks this before doing an actual resize.
The benefit of putting it here is that the processing of the first argument (source in the docs) is a bit easier if we eagerly check _canresize(). The non-null in the comment is referring to that (because when source is null, we don't do anything more than resize()).
I'm open to suggestions here :)
Fix capitalization in comment Co-authored-by: Cody Maloney <cmaloney@users.noreply.github.com>
vstinner
left a comment
There was a problem hiding this comment.
Overall, the change looks good to me. Here is a first review :-)
| def test_no_init_called(self): | ||
| # A bytearray created without calling bytearray.__init__ | ||
| # should not crash the interpreter (see gh-153419). | ||
| def uninitialized(): |
There was a problem hiding this comment.
Hum, "uninitialized" name is misleading. Maybe before the fix, the object could be "uninitialized". But with this fix, the object is now well defined. I suggest renaming the function to "bytearray_new()".
| _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(self); | ||
| PyByteArrayObject *obj = ((PyByteArrayObject *)self); | ||
|
|
||
| assert(obj->ob_bytes_object != NULL); |
There was a problem hiding this comment.
Would you mind to apply this sanity check to _PyBytes_Resize()? The empty bytes string singleton must never be resized.
diff --git a/Objects/bytesobject.c b/Objects/bytesobject.c
index f63185e1428..264ce0d9a10 100644
--- a/Objects/bytesobject.c
+++ b/Objects/bytesobject.c
@@ -3381,6 +3381,7 @@ _PyBytes_Resize(PyObject **pv, Py_ssize_t newsize)
return (*pv == NULL) ? -1 : 0;
}
+ assert(v != bytes_get_empty());
#ifdef Py_TRACE_REFS
_Py_ForgetReference(v);
#endif| if (self == NULL) { | ||
| return NULL; | ||
| } | ||
| PyByteArrayObject *obj = _PyByteArray_CAST(self); |
There was a problem hiding this comment.
I would prefer to rename self to op and rename obj to self.
| if (PyByteArray_Resize((PyObject *)self, 0) < 0) { | ||
| return -1; | ||
| } | ||
|
|
There was a problem hiding this comment.
nitpick: you can remove this empty line, since these 2 assertions are related to PyByteArray_Resize() :-)
| } | ||
|
|
||
| if (_PyBytes_Resize(&self->ob_bytes_object, to_take) == -1) { | ||
| self->ob_bytes_object = Py_GetConstant(Py_CONSTANT_EMPTY_BYTES); |
There was a problem hiding this comment.
I'm not 100% sure that _PyBytes_Resize() always set self->ob_bytes_object to NULL on error. Also, this private function can change in the future. So I would prefer to add this assertion to make sure that there is no refleak:
| self->ob_bytes_object = Py_GetConstant(Py_CONSTANT_EMPTY_BYTES); | |
| assert(self->ob_bytes_object == NULL); | |
| self->ob_bytes_object = Py_GetConstant(Py_CONSTANT_EMPTY_BYTES); |
- Add assert to bytes object resize
(ensure we're not resizing the empty constant)
- Test helper rename
- Flip obj<->self usage in bytearray_new
- Assert PyBytes_Resize failure sets ob_bytes_object to NULL
…vstinner's suggestion.
vstinner
left a comment
There was a problem hiding this comment.
LGTM. Thanks for the updates.
|
It would be interesting to backport these changes to 3.13 and 3.14, but I expect that it should be done manually to solve merge conflicts. |
|
GH-154622 is a backport of this pull request to the 3.15 branch. |
@vstinner - Thanks for the review, and finding the issues! |
…3498) (#154622) gh-153419: Fix several issues around bytearray __init__ (GH-153498) Introduce a bytearray_new() function to ensure that ob_bytes_object is always set on a bytearray. Resizing a bytearray to 0 length now explicitly sets the ob_bytes_object to the empty constant immortal. Add a check in the 'bytearray init from string' fast path to ensure there are no active exports. This fixes asserts/crashes on the following: - bytearray(1).__init__() - bytearray().__new__(bytearray).append(1) - a = bytearray(); b = memoryview(a); a.__init__('x', 'ascii') (cherry picked from commit d5c1b29) Co-authored-by: Steve Stagg <stestagg@gmail.com> Co-authored-by: Cody Maloney <cmaloney@users.noreply.github.com>
Introduce a bytearray_new function to ensure that
ob_bytes_object is always set on a bytearray.
Resizing a bytearray to 0 length explicitly sets
the ob_bytes_object to the empty constant immortal.
Add a check in the 'bytearray init from string'
fast path to ensure there are no active exports.
This fixes asserts/crashes on the following:
bytearray(1).__init__()bytearray().__new__(bytearray).append(1)a = bytearray(); b = memoryview(a); a.__init__('x', 'ascii')I realise that the explicit setting of bytes_object to
Py_CONSTANT_EMPTY_BYTESis not strictly necessary, as this is also done by the_PyBytes_Resizetoo, but this seemed to mark the intent of what's happening more clearly, given we rely and assert on that exact behaviour.