Skip to content

Fix insert() of an empty range blanking the tail - #71

Merged
martinus merged 1 commit into
mainfrom
fix-65-insert-empty-range
Jul 27, 2026
Merged

Fix insert() of an empty range blanking the tail#71
martinus merged 1 commit into
mainfrom
fix-65-insert-empty-range

Conversation

@martinus

Copy link
Copy Markdown
Owner

Fixes #65.

v.insert(pos, 0, value) blanked every element from pos onwards. No exception, no sanitizer report, just wrong data:

insert(begin(), 0, value)
  svector       [<EMPTY!> <EMPTY!> <EMPTY!> ]
  std::vector   [aaaa... bbbb... cccc... ]

Cause

make_uninitialized_space calls shift_right(p, data + s, p + count). With count == 0 the target begin equals the source begin, so the second step becomes std::move_backward(first, last, last) — destination equal to source end, which that algorithm explicitly disallows. It walks backwards self-move-assigning every element in the range.

Affects insert(pos, 0, value), insert(pos, first, first) (forward iterators) and insert(pos, {}). The input-iterator overload already had an empty-range early-out, which is why it escaped.

Where the guard goes

In shift_right, not at the call site. Its documented precondition is source_begin <= target_begin — non-strict, so the equal case was always advertised as valid input and the implementation simply did not honour it. Guarding the one caller instead would leave the primitive a footgun for the next one.

The review raised that count == 0 at the call site is easier for the compiler to fold than pointer equality inside the callee. Benchmarked both placements:

guard in shift_right guard in caller
insert(begin(), value) int 19.55 / 19.77 ns 19.63 / 19.52 ns
insert(mid, value) int 13.07 / 13.04 ns 13.05 / 13.61 ns
insert(begin(), string) 138.15 / 137.58 ns 137.13 / 135.97 ns

No difference, so correctness of the primitive wins.

Tests

Two cases in test/unit/insert.cpp, covering all three affected overloads at every position including the pos == size boundary, and once more in indirect mode. Both fail without the guard.

They use std::string deliberately. This file already had empty-range insert coverage, but with Counter::Obj, whose move assignment is a plain field copy — a self-move preserves the value, so the corruption was unobservable. That blind spot is #67.

Verified with gcc and clang under -fsanitize=address,undefined.

shift_right(p, data + s, p + count) with count == 0 has its target begin equal
to its source begin, so std::move_backward was called with its destination
equal to its source end. That is a precondition violation, and what it actually
does is self-move-assign every element from pos to the end. With std::string
they all come back empty.

Reachable from insert(pos, 0, value), insert(pos, first, first) with forward
iterators, and insert(pos, {}). The input iterator overload already returns
early for an empty range, which is why only the other two were affected.

Guarded in shift_right rather than at the call site: its documented
precondition is source_begin <= target_begin, non strict, so the equal case
was always supposed to work. Benchmarked both placements on single element
insert, no measurable difference.

The tests use std::string because the existing empty range coverage in this
file uses Counter::Obj, whose move assignment copies its fields, so a self move
preserves the value and the corruption is invisible. See #67.

Closes #65

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@martinus
martinus merged commit 0a12ef9 into main Jul 27, 2026
6 checks passed
@martinus
martinus deleted the fix-65-insert-empty-range branch July 27, 2026 18:56
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.

insert() with an empty range silently blanks every element from pos onwards

1 participant