Skip to content

memoryview: count exports, hash the exporter, and index through __index__ - #8553

Merged
youknowone merged 6 commits into
RustPython:mainfrom
youknowone:RustPython-3
Aug 18, 2026
Merged

youknowone merged 6 commits into
RustPython:mainfrom
youknowone:RustPython-3

Conversation

@youknowone

@youknowone youknowone commented Aug 18, 2026

Copy link
Copy Markdown
Member

Three memoryview tests were marked expectedFailure for "re-entrant buffer release not detected". Chasing them turned up four separate defects underneath, each visible from Python on its own.

All comparisons below are against CPython 3.14.6.

A view kept no count of what it had exported

release() always succeeded, even while something was still reading through an export:

mv = memoryview(bytearray(b'abc'))
w = mv.__buffer__(0)
mv.release()      # CPython: BufferError: memoryview has 1 exported buffer
                  # before:  returns

PyMemoryView now carries the count _memory_release consults, kept by the bf_getbuffer/bf_releasebuffer slots, and __exit__ reports the same refusal.

memory_hash never asked the exporter

A view is no more hashable than what it looks at. memory_hash hashes view->obj and throws the answer away for exactly that reason:

hash(memoryview(bytearray(b'abc')).toreadonly())
# CPython: TypeError: unhashable type: 'bytearray'
# before:  returns a hash

Asking runs Python, so the view counts as exported for the duration and a release attempted from inside that hash is refused rather than obeyed. That is test_hash_use_after_free (gh-142664).

While fixing this it turned out array.array was hashable here despite being mutable, so an array could go into a set and then be changed underneath it. tp_hash = PyObject_HashNotImplemented there; it is unhashable now.

hex(sep) never called sep.__len__

The separator's length came from its bytes rather than from the object, and the check came after the bytes_per_sep == 0 and empty-data shortcuts. Four ways for bytes, bytearray and memoryview alike:

CPython before
b'abcd'.hex(S(b'::')), __len__ → 1 61:62:63:64 ValueError
b'abcd'.hex(S(b':')), __len__ → 2 ValueError 61:62:63:64
b'abcd'.hex(S(b':')), __len__ raises propagates swallowed
b'abcd'.hex('::', 0) ValueError '61626364'

Measuring runs Python, and the bytes to be written out must not be borrowed while it does — bytearray.hex would have called __len__ holding its own lock. The separator is resolved from the arguments before the buffer is reached, which for a memoryview also means the view counts as exported for the duration. That is test_hex_use_after_free (gh-143195).

A tuple key was only taken apart when every item was already an int

c = memoryview(bytes(range(24))).cast('B', shape=(4, 6))
c[I(), 2]        # I has __index__; CPython: 8, before: TypeError: invalid slice key

What kind of key it is now follows from the types in it, the way is_multiindex decides, and each item is converted where it sits. An item that answers __index__ but raises on the way reports that rather than making the whole key invalid. The conversion runs Python and can release the view, so the multi-dimensional read goes through unpack_single and its re-check. That is test_use_released_memory (gh-92888).

IndexError also named the index that was out of bounds where lookup_dimension names the dimension, counted from one:

c = memoryview(bytearray(range(24))).cast('B', shape=(2, 3, 4))
c[0, 0, -5]   # CPython: index out of bounds on dimension 3
              # before:  index out of bounds on dimension -5

and the one-dimensional path had a message of its own (index out of range).

cast() accepted any number of dimensions

memoryview(bytearray(range(8))).cast('B', (1,)*64 + (8,))
# CPython: ValueError: memoryview: number of dimensions must not exceed 64
# before:  a 65-dimensional view

Tests

test_hash_use_after_free, test_hex_use_after_free and test_use_released_memory pass, and their expectedFailure decorators are removed. Six regression tests were added to extra_tests/snippets/builtin_memoryview.py, all of which pass under CPython 3.14.6 as well.

20 suites run clean locally — memoryview, buffer, bytes, struct, array, io, binascii, mmap, hashlib, codecs, zlib, bz2, marshal, socket, ssl, pickle, hmac, collections, set, dict.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P9HewXGX8qcGSccUxGdSPV

Summary by CodeRabbit

  • Bug Fixes

    • Improved memoryview handling for exported buffers, hashing, indexing, casting, and context-manager release behavior.
    • Improved bounds and conversion error messages for multidimensional views.
    • Fixed validation of separators used by bytes.hex() and bytearray.hex().
    • Marked array.array objects as unhashable.
  • Tests

    • Added regression coverage for memoryview exports, indexing, hashing, casting, bounds errors, and hexadecimal separators.

A tuple key was taken apart only when every item was already an `int`, so
`m[I(), 2]` with an `__index__` on `I` was reported as an invalid slice key
rather than indexed. What kind of key it is now follows from the types in
it, the way `is_multiindex` decides, and each item is converted where it
sits; an item that answers `__index__` but raises on the way reports that
rather than making the whole key invalid.

That conversion runs Python, which can release the view, so the
multi-dimensional read goes through `unpack_single` and its re-check
rather than reading the bytes itself.

`IndexError` named the index that was out of bounds where
`lookup_dimension` names the dimension it was out of bounds on, counted
from one, and the one-dimensional path had a message of its own.

Assisted-by: Claude
The view kept no count of the buffers taken from it, so `release()` always
succeeded, even while something was still reading through an export. It now
answers `BufferError` the way `_memory_release` does, and `__exit__` reports
the same refusal.

`memory_hash` asks the exporter for its hash and throws the answer away, so
a view is no more hashable than what it looks at; a read-only view over a
`bytearray` hashed here where CPython refuses it. Asking runs Python, so the
view counts as exported for the duration and a release attempted from inside
that hash is refused rather than obeyed.

Assisted-by: Claude
`hex()` took the separator's length from its bytes rather than from the
object, so a `bytes` subclass that answers `__len__` was measured by what it
holds instead of what it says: `b'abcd'.hex(S(b'::'))` with `__len__`
returning 1 was refused where `_Py_strhex_impl` accepts it, and a two-byte
answer was accepted where it refuses. The check also came after the
`bytes_per_sep == 0` and empty-data shortcuts, so `b'abcd'.hex('::', 0)`
never reached it at all.

Measuring runs Python, and the bytes to be written out must not be borrowed
while it does, so the separator is now resolved from the arguments before
the buffer is reached. For a memoryview that also means the view counts as
exported for the duration, so a release attempted from inside `__len__` is
refused.

Assisted-by: Claude
`array.array` is mutable but inherited `object.__hash__`, so an array could
be put in a set or used as a dict key and then changed underneath it. It is
unhashable, as `tp_hash = PyObject_HashNotImplemented` makes it.

A read-only memoryview over an array now reports the exporter as unhashable
too, which is what `memory_hash` asks it for.

Assisted-by: Claude
test_hash_use_after_free, test_hex_use_after_free and
test_use_released_memory pass.

Assisted-by: Claude
`cast()` accepted a shape of any length, so `mv.cast('B', (1,)*64 + (8,))`
built a 65-dimensional view where `memoryview_cast_impl` refuses anything
past `PyBUF_MAX_NDIM`. The limit is answered before the shape is looked at
any further, as it is there.

Assisted-by: Claude
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: fee0f072-69a3-4162-813f-657ef59734ab

📥 Commits

Reviewing files that changed from the base of the PR and between 67cf607 and 3cd7a75.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_memoryview.py is excluded by !Lib/**
📒 Files selected for processing (7)
  • crates/stdlib/src/array.rs
  • crates/vm/src/builtins/bytearray.rs
  • crates/vm/src/builtins/bytes.rs
  • crates/vm/src/builtins/memory.rs
  • crates/vm/src/bytes_inner.rs
  • crates/vm/src/protocol/buffer.rs
  • extra_tests/snippets/builtin_memoryview.py

📝 Walkthrough

Walkthrough

The PR marks array.array as unhashable, resolves hex separators before buffer access, and updates memoryview export tracking, indexing errors, subscript conversion, hashing, and cast dimensionality checks. Regression tests cover the changed behavior.

Changes

Memoryview and hex behavior

Layer / File(s) Summary
Hex option resolution and conversion
crates/vm/src/bytes_inner.rs, crates/vm/src/builtins/bytes.rs, crates/vm/src/builtins/bytearray.rs, crates/vm/src/builtins/memory.rs, extra_tests/snippets/builtin_memoryview.py
Hex separators are resolved and validated before conversion. Bytes, bytearray, and memoryview use the resolved separator representation.
Export lifecycle and callback protection
crates/vm/src/builtins/memory.rs, extra_tests/snippets/builtin_memoryview.py
Memoryviews count active exports, reject release while exported, and protect hashing and callback-driven operations.
Indexing and dimensionality validation
crates/vm/src/builtins/memory.rs, crates/vm/src/protocol/buffer.rs, extra_tests/snippets/builtin_memoryview.py
Index conversion errors propagate, bounds errors identify dimensions, and casts reject shapes with more than 64 dimensions.

Array hashability

Layer / File(s) Summary
Array hashability declaration
crates/stdlib/src/array.rs
The array class is explicitly marked unhashable.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PythonCaller
  participant PyMemoryView
  participant Exporter
  PythonCaller->>PyMemoryView: hash(memoryview)
  PyMemoryView->>Exporter: hash underlying exporter while exported
  Exporter-->>PyMemoryView: hash result or error
  PyMemoryView-->>PythonCaller: return hash result
Loading

Possibly related PRs

Suggested labels: z-ca-2026

Suggested reviewers: shaharnaveh

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[ ] test: cpython/Lib/test/test_memoryview.py (TODO: 4)

dependencies:

dependent tests: (no tests depend on memoryview)

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

@youknowone
youknowone marked this pull request as ready for review August 18, 2026 13:18
@youknowone
youknowone merged commit 182175b into RustPython:main Aug 18, 2026
28 of 29 checks passed
@youknowone
youknowone deleted the RustPython-3 branch August 18, 2026 13:19
youknowone added a commit that referenced this pull request Sep 16, 2026
…dex__` (#8553)

* memoryview: index through `__index__` and name the dimension

A tuple key was taken apart only when every item was already an `int`, so
`m[I(), 2]` with an `__index__` on `I` was reported as an invalid slice key
rather than indexed. What kind of key it is now follows from the types in
it, the way `is_multiindex` decides, and each item is converted where it
sits; an item that answers `__index__` but raises on the way reports that
rather than making the whole key invalid.

That conversion runs Python, which can release the view, so the
multi-dimensional read goes through `unpack_single` and its re-check
rather than reading the bytes itself.

`IndexError` named the index that was out of bounds where
`lookup_dimension` names the dimension it was out of bounds on, counted
from one, and the one-dimensional path had a message of its own.

Assisted-by: Claude

* memoryview: count what a view has exported, and hash the exporter

The view kept no count of the buffers taken from it, so `release()` always
succeeded, even while something was still reading through an export. It now
answers `BufferError` the way `_memory_release` does, and `__exit__` reports
the same refusal.

`memory_hash` asks the exporter for its hash and throws the answer away, so
a view is no more hashable than what it looks at; a read-only view over a
`bytearray` hashed here where CPython refuses it. Asking runs Python, so the
view counts as exported for the duration and a release attempted from inside
that hash is refused rather than obeyed.

Assisted-by: Claude

* bytes, bytearray, memoryview: measure the hex separator as `len()` does

`hex()` took the separator's length from its bytes rather than from the
object, so a `bytes` subclass that answers `__len__` was measured by what it
holds instead of what it says: `b'abcd'.hex(S(b'::'))` with `__len__`
returning 1 was refused where `_Py_strhex_impl` accepts it, and a two-byte
answer was accepted where it refuses. The check also came after the
`bytes_per_sep == 0` and empty-data shortcuts, so `b'abcd'.hex('::', 0)`
never reached it at all.

Measuring runs Python, and the bytes to be written out must not be borrowed
while it does, so the separator is now resolved from the arguments before
the buffer is reached. For a memoryview that also means the view counts as
exported for the duration, so a release attempted from inside `__len__` is
refused.

Assisted-by: Claude

* array: refuse to hash an array

`array.array` is mutable but inherited `object.__hash__`, so an array could
be put in a set or used as a dict key and then changed underneath it. It is
unhashable, as `tp_hash = PyObject_HashNotImplemented` makes it.

A read-only memoryview over an array now reports the exporter as unhashable
too, which is what `memory_hash` asks it for.

Assisted-by: Claude

* Remove expectedFailure from three memoryview tests

test_hash_use_after_free, test_hex_use_after_free and
test_use_released_memory pass.

Assisted-by: Claude

* memoryview: bound the dimensions a cast can name

`cast()` accepted a shape of any length, so `mv.cast('B', (1,)*64 + (8,))`
built a 65-dimensional view where `memoryview_cast_impl` refuses anything
past `PyBUF_MAX_NDIM`. The limit is answered before the shape is looked at
any further, as it is there.

Assisted-by: Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant