From fad1503645738d99ebdd41d34b3f747a8dedf2a6 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 5 Sep 2026 17:33:24 +0100 Subject: [PATCH 1/4] Fix `bytearray.take_bytes()` corrupting shared single-byte bytes objects --- Lib/test/test_bytes.py | 8 ++++++++ .../2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst | 3 +++ Objects/bytearrayobject.c | 8 +++++--- 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 1b9918c6c8f473..05513cf5421584 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -1611,6 +1611,14 @@ def test_take_bytes(self): self.assertRaises(BufferError, ba.take_bytes) self.assertEqual(ba.take_bytes(), b'abc') + # Leaving one byte must not adopt the shared single-byte bytes object + # as the buffer. + ba = bytearray(b'abc') + self.assertEqual(ba.take_bytes(2), b'ab') + ba[0] = ord('A') + self.assertEqual(ba, bytearray(b'A')) + self.assertEqual(ord(b'c'), ord('c')) + @support.cpython_only # tests an implementation detail def test_take_bytes_optimization(self): # Validate optimization around taking lots of little chunks out of a diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst new file mode 100644 index 00000000000000..6c47877397686b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst @@ -0,0 +1,3 @@ +Fix :meth:`bytearray.take_bytes` leaving the bytearray with a shared buffer +when exactly one byte remained, so that writing to the bytearray modified +the single-byte :class:`bytes` object with the same value. diff --git a/Objects/bytearrayobject.c b/Objects/bytearrayobject.c index 055fedc3ddfb03..175efa346d88d7 100644 --- a/Objects/bytearrayobject.c +++ b/Objects/bytearrayobject.c @@ -1619,12 +1619,14 @@ bytearray_take_bytes_impl(PyByteArrayObject *self, PyObject *n) return ret; } - // Copy remaining bytes to a new bytes. - PyObject *remaining = PyBytes_FromStringAndSize(self->ob_start + to_take, - remaining_length); + // Copy remaining bytes to a new bytes. Allocate and then copy rather than + // so we don't get a shared immortal one-character singleton! + PyObject *remaining = PyBytes_FromStringAndSize(NULL, remaining_length); if (remaining == NULL) { return NULL; } + memcpy(PyBytes_AS_STRING(remaining), self->ob_start + to_take, + remaining_length); // If the bytes are offset inside the buffer must first align. if (self->ob_start != self->ob_bytes) { From a67dbe976ff0aa5d633ac7214aa8c906b4e3ac77 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 5 Sep 2026 19:23:58 +0100 Subject: [PATCH 2/4] Fixup comment --- Objects/bytearrayobject.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Objects/bytearrayobject.c b/Objects/bytearrayobject.c index 175efa346d88d7..1496f83aecf744 100644 --- a/Objects/bytearrayobject.c +++ b/Objects/bytearrayobject.c @@ -1619,7 +1619,7 @@ bytearray_take_bytes_impl(PyByteArrayObject *self, PyObject *n) return ret; } - // Copy remaining bytes to a new bytes. Allocate and then copy rather than + // Copy remaining bytes to a new bytes. Allocate and then copy // so we don't get a shared immortal one-character singleton! PyObject *remaining = PyBytes_FromStringAndSize(NULL, remaining_length); if (remaining == NULL) { From 125aa2aeca724e2a4421c308659d576cf90de84c Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 5 Sep 2026 20:45:39 +0100 Subject: [PATCH 3/4] Apply Cody's suggestions Co-authored-by: Cody Maloney --- Lib/test/test_bytes.py | 13 +++++++++++++ .../2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst | 8 +++++--- Objects/bytearrayobject.c | 2 ++ Objects/bytesobject.c | 14 ++++++-------- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index 05513cf5421584..e73cd7d5d826bc 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -3063,5 +3063,18 @@ def resize_stress(ba): with threading_helper.start_threads(threads): pass + @threading_helper.reap_threads + @threading_helper.requires_working_threading() + def test_free_threading_bytearray_resize_other_thread(self): + # Shrinking a bytearray whose buffer another thread owns must not + # adopt the immortal single-byte bytes object a the buffer. + ba = bytearray(b'abc') + thread = threading.Thread(target=ba.resize, args=(1,)) + with threading_helper.start_threads([thread]): + pass + ba[0] = ord('X') + self.assertEqual(ba, bytearray(b'X')) + self.assertEqual(ord(b'a'), ord('a')) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst index 6c47877397686b..8d5970e7f4a374 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-05-17-32-11.gh-issue-156995.Tk3bYt.rst @@ -1,3 +1,5 @@ -Fix :meth:`bytearray.take_bytes` leaving the bytearray with a shared buffer -when exactly one byte remained, so that writing to the bytearray modified -the single-byte :class:`bytes` object with the same value. +Fix :class:`bytearray` sharing its buffer with the single-byte :class:`bytes` +object of the same value, so that writing to the bytearray modified that +:class:`bytes` object. This happened with :meth:`bytearray.take_bytes` when +exactly one byte remained, and on the free-threaded build when a bytearray was +shrunk to one byte from another thread. diff --git a/Objects/bytearrayobject.c b/Objects/bytearrayobject.c index 1496f83aecf744..4e2d93b4963211 100644 --- a/Objects/bytearrayobject.c +++ b/Objects/bytearrayobject.c @@ -46,6 +46,8 @@ _getbytevalue(PyObject* arg, int *value) static void bytearray_reinit_from_bytes(PyByteArrayObject *self, Py_ssize_t size, Py_ssize_t alloc) { + /* Only the empty bytes may be immortal. */ + assert((alloc == 0) == _Py_IsImmortal(self->ob_bytes_object)); self->ob_bytes = self->ob_start = PyBytes_AS_STRING(self->ob_bytes_object); Py_SET_SIZE(self, size); FT_ATOMIC_STORE_SSIZE_RELAXED(self->ob_alloc, alloc); diff --git a/Objects/bytesobject.c b/Objects/bytesobject.c index 4c3da93f101970..75f3db3aff1310 100644 --- a/Objects/bytesobject.c +++ b/Objects/bytesobject.c @@ -3368,14 +3368,12 @@ _PyBytes_Resize(PyObject **pv, Py_ssize_t newsize) return 0; } if (!_PyObject_IsUniquelyReferenced(v)) { - if (oldsize < newsize) { - *pv = _PyBytes_FromSize(newsize, 0); - if (*pv) { - memcpy(PyBytes_AS_STRING(*pv), PyBytes_AS_STRING(v), oldsize); - } - } - else { - *pv = PyBytes_FromStringAndSize(PyBytes_AS_STRING(v), newsize); + // Allocate and then copy so we don't get a shared immortal + // one-character singleton! + *pv = _PyBytes_FromSize(newsize, 0); + if (*pv) { + memcpy(PyBytes_AS_STRING(*pv), PyBytes_AS_STRING(v), + Py_MIN(oldsize, newsize)); } Py_DECREF(v); return (*pv == NULL) ? -1 : 0; From b781cabb5b3ea2752ca6bb325c5ad031fe287fee Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 5 Sep 2026 22:03:54 +0100 Subject: [PATCH 4/4] Update Objects/bytearrayobject.c Co-authored-by: Cody Maloney --- Objects/bytearrayobject.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Objects/bytearrayobject.c b/Objects/bytearrayobject.c index 4e2d93b4963211..5e6639f3dd74c6 100644 --- a/Objects/bytearrayobject.c +++ b/Objects/bytearrayobject.c @@ -45,7 +45,8 @@ _getbytevalue(PyObject* arg, int *value) static void bytearray_reinit_from_bytes(PyByteArrayObject *self, Py_ssize_t size, - Py_ssize_t alloc) { + Py_ssize_t alloc) +{ /* Only the empty bytes may be immortal. */ assert((alloc == 0) == _Py_IsImmortal(self->ob_bytes_object)); self->ob_bytes = self->ob_start = PyBytes_AS_STRING(self->ob_bytes_object);