Skip to content

fix(stl_bind): correct __delitem__ for negative-step slices and re-enable contiguous erase fast path - #6088

Merged
rwgk merged 6 commits into
pybind:masterfrom
henryiii:fix/stl-bind-delitem-negative-step
Jul 26, 2026
Merged

fix(stl_bind): correct __delitem__ for negative-step slices and re-enable contiguous erase fast path#6088
rwgk merged 6 commits into
pybind:masterfrom
henryiii:fix/stl-bind-delitem-negative-step

Conversation

@henryiii

@henryiii henryiii commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Bug

bind_vector's __delitem__(slice) in stl_bind.h advanced the erase
index by step - 1 for every slice, but that correction is only valid
for positive steps (where erasing an element shifts the later
elements down by one). For negative steps the visited indices are
strictly decreasing, so erasing never shifts them and the - 1 is
wrong:

  • del v[::-2] on [0, 1, 2, 3] produced [1, 2] instead of the
    correct [0, 2] (silent data corruption).
  • del v[::-1] ended up calling v.erase(v.begin() - 1), an
    out-of-bounds access (observed SIGBUS).

Separately, the contiguous fast path was guarded by step == 1 && false
— a debugging artifact from 2016 (25c03ce) — so a contiguous delete
like del v[1:1000] did 999 individual erase calls instead of one
range erase.

Fix

Semantics now match CPython del l[slice] exactly for forward,
strided, negative, empty, and full slices.

Tests

Added test_vector_delitem_slice in tests/test_stl_binders.py,
looping over forward / strided / negative-step / empty / full slices for
vectors of several lengths and comparing against the equivalent Python
list del.

Verification

Built a scratch opaque VectorInt module against this branch's headers
and compared del v[...] to Python list semantics across all the slice
cases above (and more) for lengths 0–11 — all match, no out-of-bounds
access.

Part of #6084


📚 Documentation preview 📚: https://pybind11--6088.org.readthedocs.build/

@henryiii henryiii mentioned this pull request Jun 11, 2026
5 tasks
Comment thread include/pybind11/stl_bind.h Outdated
Comment thread tests/test_stl_binders.py Outdated
@henryiii
henryiii marked this pull request as ready for review June 15, 2026 19:07
@henryiii
henryiii force-pushed the fix/stl-bind-delitem-negative-step branch from e5c1c6d to ee2b050 Compare June 17, 2026 12:14
henryiii added 2 commits June 17, 2026 15:45
…able contiguous erase fast path

The slice __delitem__ binding advanced the erase index by step - 1 for
all steps. That correction is only valid for positive steps, where
erasing shifts later elements down by one. For negative steps the
visited indices are strictly decreasing and erasing never shifts them,
so the extra -1 deleted the wrong elements (e.g. del v[::-2] on
[0,1,2,3] yielded [1,2] instead of [0,2]) and del v[::-1] walked off the
front of the vector (v.begin() - 1, observed SIGBUS).

Switch to the signed slice::compute overload so negative steps stay
signed, advance by step for negative steps and step - 1 for positive
ones, and drop the && false that had disabled the O(n) contiguous fast
path since 2016.

Assisted-by: ClaudeCode:claude-fable-5
Use static_cast instead of a C-style cast for the slice.compute() size
argument, and convert the __delitem__ slice test to
pytest.mark.parametrize over the slice cases.

Assisted-by: ClaudeCode:claude-fable-5
@henryiii
henryiii force-pushed the fix/stl-bind-delitem-negative-step branch from ee2b050 to 68f02bd Compare June 17, 2026 19:45
rwgk added 3 commits July 25, 2026 23:46
The control flow handles all relevant boundaries:

- slicelength == 0: excluded by the outer guard.
- slicelength == 1: erases once, decrements to zero, and breaks without touching start.
- Larger slices: updates start exactly when another erase remains.
- slicelength cannot underflow because the loop exits when it reaches zero.
- Mutating slicelength is harmless because it is not used afterward.
- The potentially dangerous final start += step remains eliminated.

It also removes the separate loop counter. The compiler would probably
optimize the former i + 1, ++i mechanics away, but the new source expresses
the real state more directly: "number of erasures remaining."

The unconditional while (true) is safe because entry is strictly guarded by
slicelength > 0, and the decrement guarantees eventual termination.
@rwgk

rwgk commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Background on the additional commits

I pushed three follow-up commits after another close review of the slice-deletion changes.

Codex originally found a signed-overflow edge case in the forward-erasure loop. The loop updated start after every erase, including the final one. For example, slice(3, None, sys.maxsize) selects only one element, but after erasing it the old code still evaluated start += step - 1. With step == SSIZE_MAX, that unused final calculation could overflow signed ssize_t, which is undefined behavior.

Looking at that finding manually led to a more fundamental question: for a positive stride, why erase the selected elements from the front? If a slice selects indices 1, 3, 5, 7, erasing them as 7, 5, 3, 1 means that no erase shifts an index that remains to be processed. Negative-step slices already arrive in descending order, so the implementation can put both cases on the same path by starting a positive-step slice at its last selected index and negating the step.

This is simpler than compensating for forward erasure with step - 1, and for std::vector it is also more efficient because elements that are themselves scheduled for deletion are not repeatedly shifted. It also resolves the original overflow concern structurally: the large positive step is converted to a negative descending step, and the loop performs no index update after the final erase.

The additional commits are:

  • 94df278 — add coverage for a sys.maxsize positive step, a negative step with negative bounds, and the zero-step error path (including verifying that the vector remains unchanged);
  • 105011e — erase non-contiguous slices in descending index order, while retaining the contiguous step == 1 range-erase fast path; and
  • 1c64b2c — simplify the loop by using slicelength directly as the number of erasures remaining, eliminating the separate loop variable and the i + 1/++i bookkeeping.

pre-commit and the full test suite pass locally; the newly triggered CI is running now.

@rwgk
rwgk merged commit 57e7a8d into pybind:master Jul 26, 2026
78 checks passed
@rwgk
rwgk deleted the fix/stl-bind-delitem-negative-step branch July 26, 2026 10:14
@github-actions github-actions Bot added the needs changelog Possibly needs a changelog entry label Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs changelog Possibly needs a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants