Skip to content

VecDeque: collecting an exhausted vec::IntoIter yields head == capacity, later failing wrap_index's debug assertion #162452

Description

@jrey8343

Collecting an exhausted vec::IntoIter into a VecDeque goes through vec::IntoIter::into_vecdeque, which passes initialized = ptr.offset_from_unsigned(buf)..end.offset_from_unsigned(buf) to VecDeque::from_contiguous_raw_parts_in. When the original Vec had capacity() == len() (e.g. vec![1, 2, 3]) and every element was consumed, that range is capacity..capacity, so the resulting deque has head == capacity (with len == 0). The field documentation on VecDeque says

// `head < buf.capacity()`, unless `buf.capacity() == 0` when `head == 0`.
head: usize,

so this is a state outside the documented invariant. It is mostly harmless because wrap_index maps head + i with head == capacity onto i, but once the deque is filled back up to len == capacity without reallocating, any path that computes to_physical_idx(len) evaluates wrap_index(2 * capacity, capacity), whose debug_assert! requires logical_index - capacity < capacity. With a standard library built with debug assertions this panics; in release builds wrap_index returns capacity and the callers I looked at handle it (e.g. write_iter_wrapping gets head_room == 0 and writes nothing).

Reproducer

use std::collections::VecDeque;

fn main() {
    let v: Vec<i32> = vec![1, 2, 3]; // capacity == len
    let mut it = v.into_iter();
    it.by_ref().for_each(drop); // exhaust it: ptr == end == buf + capacity
    let mut d: VecDeque<i32> = it.collect(); // head == 3 == capacity, len == 0
    d.push_back(1);
    d.push_back(2);
    d.push_back(3); // full, no reallocation
    d.extend(std::iter::empty::<i32>()); // to_physical_idx(3) == wrap_index(6, 3)
    println!("{d:?}");
}

Run with a debug-assertions std, e.g.

$ cat Cargo.toml
...
[profile.dev]
debug-assertions = true
$ cargo +nightly run -Zbuild-std --target aarch64-apple-darwin
thread 'main' panicked at .../library/alloc/src/collections/vec_deque/mod.rs:3472:5:
assertion failed: (logical_index == 0 && capacity == 0) || logical_index < capacity ||
    (logical_index - capacity) < capacity

Expected: no panic (the deque is simply empty at that point and the extend adds nothing).

Meta

rustc 1.93.0-nightly (01867557c 2025-11-12) on aarch64-apple-darwin; the relevant code is unchanged on current master (into_iter.rs into_vecdeque, vec_deque/mod.rs wrap_index / from_contiguous_raw_parts_in).

Possible fix

Normalize the head in from_contiguous_raw_parts_in when the initialized range is empty (or specifically when initialized.start == capacity), e.g. head: if initialized.start == initialized.end { 0 } else { initialized.start }, which restores the documented invariant; alternatively teach into_vecdeque to pass 0..0 for an exhausted iterator (it already does so for ZSTs).

Found while writing Kani proofs for VecDeque in model-checking#681.

Activity

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Sep 8, 2026
  2. maxdexh commented on Sep 8, 2026

    @maxdexh
    Member

    Thanks for finding this!

    This is a violation of the internal invariants of VecDeque. I'm not sure it actually leads to memory corruption right now, but it is unsound in the sense that new code could. It does, see comment below.

    Here's a fun way to see the fields without building the standard library.

    use std::collections::VecDeque;
    
    fn main() {
        let print_parts = |d: &_| {
            // don't do this in real code, this is library UB ^^
            let parts = unsafe {
                const PARTS: usize = size_of::<VecDeque<i32>>() / size_of::<usize>();
                std::mem::transmute_copy::<_, [usize; PARTS]>(d)
            };
            eprintln!("{:?}", parts)
        };
    
        let v: Vec<i32> = vec![1, 2, 3]; // capacity == len
        let mut it = v.into_iter();
        it.by_ref().for_each(drop); // exhaust it: ptr == end == buf + capacity
        let mut d: VecDeque<i32> = it.collect(); // head == 3 == capacity, len == 0
    
        print_parts(&d); // [3, 102488265907664, 3, 0]
    
        d.push_back(1);
        d.push_back(2);
        d.push_back(3);
    
        print_parts(&d); // [3, 102488265907664, 3, 3]
    
        eprintln!("{d:?}");
    }

    @rustbot label A-collections I-unsound T-libs C-bug

  3. added
    I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/Soundness
    T-libsRelevant to the library team, which will review and decide on the PR/issue.
    I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}
    on Sep 8, 2026
  4. maxdexh commented on Sep 8, 2026

    @maxdexh
    Member

    Here's an example that segfaults:

    use std::collections::VecDeque;
    
    fn main() {
        for n in 1..200 {
            let v = vec![vec![vec![1]]; n];
    
            let mut it = v.into_iter();
            for _ in &mut it {}
    
            let mut d: VecDeque<_> = it.collect(); // head = n, cap = n, len = 0
    
            d.push_back(vec![]); // head = n, cap = n, len = 1
    
            // Reads from one-past-the-end of buffer (run with miri to see this)
            let wat = d.pop_front();
    
            eprintln!("{n}: {wat:?}");
        }
    }
  5. maxdexh commented on Sep 8, 2026

    @maxdexh
    Member

    @rustbot claim

  6. jrey8343 commented on Sep 8, 2026

    @jrey8343
    Author

    Thanks for the quick triage and the segfaulting example. For what it's worth, pop_front is the direct culprit on the read side: it does buffer_read(old_head) with the unwrapped self.head, so head == capacity reads slot capacity. On the verification side (model-checking#681) from_contiguous_raw_parts_in now carries the precondition initialized.start < capacity || initialized.start == 0 (and ensures head < capacity unless the capacity is 0), which is the condition into_vecdeque needs to satisfy; normalizing an empty range to 0..0 in either function satisfies it.

  7. maxdexh commented on Sep 8, 2026

    @maxdexh
    Member

    Yeah, the VecDeque code is as much of a mess as I remember it. It has gotten slightly better with the introduction of WrappedIndex, except that the safety invariants are still not documented and handled as strictly as they probably should be.

    I'll probably add safety requirements to from_contiguous_raw_parts_in and refactor into_vecdeque

  8. removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Sep 8, 2026
  9. removed
    I-prioritizeIssue needs a team member to assess the impact. Will be replaced by P-{low,medium,high,critical}
    on Sep 8, 2026
  10. rustbot commented on Sep 8, 2026

    @rustbot
    Collaborator

    Assigning P-high (discussion on Zulip).

  11. added a commit that references this issue on Sep 8, 2026
    29b15e6
  12. added a commit that references this issue on Sep 8, 2026
    18c20d2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

A-collectionsArea: `std::collections`C-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessP-highHigh priorityT-libsRelevant to the library team, which will review and decide on the PR/issue.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions