From 7b45b78068ac60a8e8a12e307704799728097f4e Mon Sep 17 00:00:00 2001 From: Roman Varnava Date: Sat, 31 Oct 2026 16:44:47 +0300 Subject: [PATCH 1/2] test: unit tests for LazyHeap and LinkedList Cover the heap invariant, lazy sort keys and pending pushes in LazyHeap, and links, the free list cap and edge cases in LinkedList. Each type also gets a random-operations test against a simple std model. None of this code calls into libpython, so all of it runs under Miri. --- src/internal/lazyheap.rs | 256 ++++++++++++++++++++++++++++++++++++ src/internal/linked_list.rs | 163 ++++++++++++++++++++++- 2 files changed, 418 insertions(+), 1 deletion(-) diff --git a/src/internal/lazyheap.rs b/src/internal/lazyheap.rs index b9ee22c..699de41 100644 --- a/src/internal/lazyheap.rs +++ b/src/internal/lazyheap.rs @@ -563,3 +563,259 @@ unsafe impl Send for RawIter {} unsafe impl Sync for RawIter {} unsafe impl Send for Cursor {} unsafe impl Sync for Cursor {} + +#[cfg(test)] +mod tests { + use super::*; + + /// `label` owns heap memory, so Miri reports a leak or double free. + struct Item { + key: u32, + label: String, + slot: HeapSlot, + } + + impl HeapNode for Item { + type Key = u32; + fn heap_key(&self) -> u32 { + self.key + } + fn heap_slot(&self) -> &HeapSlot { + &self.slot + } + fn heap_slot_mut(&mut self) -> &mut HeapSlot { + &mut self.slot + } + } + + fn item(key: u32, label: &str) -> Item { + Item { + key, + label: label.to_string(), + slot: HeapSlot::default(), + } + } + + /// Positions match the buffer, the heap property holds on `(rec, seq)`, + /// pending entries point back at their slot, and every element that is not + /// pending has a recorded key no greater than its live key. + fn check(heap: &LazyHeap) { + for (i, ptr) in heap.data.iter().enumerate() { + let node = unsafe { ptr.as_ref() }; + let slot = node.heap_slot(); + assert_eq!(slot.pos as usize, i, "pos of {}", node.label); + if i > 0 { + assert!(unsafe { !heap.less(i, (i - 1) / 2) }, "heap order at {i}"); + } + if slot.pidx == NOT_PENDING { + assert!(slot.rec <= node.key, "lazy key of {} decreased", node.label); + } else { + assert_eq!(heap.pending[slot.pidx as usize], *ptr); + } + } + for ptr in &heap.pending { + assert!(heap.data.contains(ptr), "pending element not in the heap"); + } + } + + fn labels(heap: &mut LazyHeap) -> Vec { + heap.iter() + .map(|c| unsafe { c.element() }.label.clone()) + .collect() + } + + #[test] + fn pop_front_orders_by_key_then_insertion() { + let mut heap = LazyHeap::new(); + for (key, label) in [(5, "a"), (3, "b"), (8, "c"), (3, "d"), (1, "e")] { + heap.push(item(key, label)); + check(&heap); + } + let mut popped = Vec::new(); + while let Some(it) = heap.pop_front() { + popped.push(it.label); + check(&heap); + } + assert_eq!(popped, ["e", "b", "d", "a", "c"]); + assert!(heap.is_empty()); + } + + #[test] + fn lazy_increase_is_repaired_at_the_front() { + let mut heap = LazyHeap::new(); + let mut a = heap.push(item(1, "a")); + heap.push(item(2, "b")); + heap.push(item(3, "c")); + + unsafe { a.element_mut().key = 10 }; + heap.mark_unsorted(); + check(&heap); + + let front = heap.front().unwrap(); + assert_eq!(unsafe { front.element() }.label, "b"); + check(&heap); + let order: Vec<_> = std::iter::from_fn(|| heap.pop_front().map(|it| it.label)).collect(); + assert_eq!(order, ["b", "c", "a"]); + } + + #[test] + fn mark_changed_handles_decrease_and_duplicates() { + let mut heap = LazyHeap::new(); + heap.push(item(1, "a")); + heap.push(item(2, "b")); + let mut c = heap.push(item(9, "c")); + + unsafe { c.element_mut().key = 0 }; + heap.mark_changed(c); + heap.mark_changed(c); // already pending: no second entry + assert_eq!(heap.pending.len(), 1); + check(&heap); + + let front = heap.front().unwrap(); + assert_eq!(unsafe { front.element() }.label, "c"); + assert!(heap.pending.is_empty()); + check(&heap); + } + + #[test] + fn remove_pending_elements_keeps_pending_indexes() { + let mut heap = LazyHeap::new(); + let cursors: Vec<_> = (0..6) + .map(|i| heap.push(item(i, &format!("n{i}")))) + .collect(); + for &c in &cursors[1..4] { + heap.mark_changed(c); + } + // removing the first pending entry moves the last one into its index + assert_eq!(heap.remove(cursors[1]).label, "n1"); + check(&heap); + assert_eq!(heap.data.last().unwrap().as_ptr(), cursors[4].as_ptr()); + assert_eq!(heap.remove(cursors[4]).label, "n4"); // last in the buffer + check(&heap); + assert_eq!(heap.remove(cursors[0]).label, "n0"); // the root + check(&heap); + assert_eq!(heap.len(), 3); + assert_eq!(labels(&mut heap), ["n2", "n3", "n5"]); + } + + #[test] + fn iter_back_pop_back_and_get_follow_sorted_order() { + let mut heap = LazyHeap::new(); + for (key, label) in [(4, "a"), (2, "b"), (4, "c"), (1, "d")] { + heap.push(item(key, label)); + } + assert_eq!(labels(&mut heap), ["d", "b", "a", "c"]); + check(&heap); + let second = heap.get(1).unwrap(); + assert_eq!(unsafe { second.element() }.label, "b"); + let back = heap.back().unwrap(); + assert_eq!(unsafe { back.element() }.label, "c"); + assert_eq!(heap.pop_back().unwrap().label, "c"); + check(&heap); + assert!(heap.get(3).is_none()); + } + + #[test] + fn clear_drop_and_shrink_free_everything() { + let mut heap = LazyHeap::new(); + let cursors: Vec<_> = (0..5) + .map(|i| heap.push(item(i, &format!("n{i}")))) + .collect(); + heap.mark_changed(cursors[2]); + heap.clear(); + assert!(heap.is_empty() && heap.pending.is_empty()); + heap.shrink_to_fit(); + + heap.push(item(7, "x")); + heap.push(item(3, "y")); + check(&heap); + // dropped with elements inside: Miri reports a leak or double free + } + + /// Random operations against a plain model; after every pop the element must + /// be the minimum (or maximum) by `(live key, insertion order)`. + #[test] + fn random_operations_match_a_model() { + let mut rng = fastrand::Rng::with_seed(0x5eed); + let mut heap = LazyHeap::new(); + // (cursor, seq) of live elements; the key is read through the cursor + let mut live: Vec<(Cursor, u64)> = Vec::new(); + let mut next_seq = 0u64; + + let key_of = |c: &Cursor| unsafe { c.element() }.key; + let extreme = |live: &[(Cursor, u64)], max: bool| { + let it = live.iter().map(|(c, s)| (key_of(c), *s)); + if max { it.max() } else { it.min() } + }; + + for step in 0..400 { + match rng.u8(0..9) { + 0..=2 => { + let c = heap.push(item(rng.u32(0..20), &format!("s{next_seq}"))); + live.push((c, next_seq)); + next_seq += 1; + } + 3 if !live.is_empty() => { + let want = extreme(&live, false).unwrap(); + let it = heap.pop_front().unwrap(); + let got = (it.key, it.label); + assert_eq!( + got, + (want.0, format!("s{}", want.1)), + "pop_front at step {step}" + ); + let pos = live.iter().position(|(_, s)| *s == want.1).unwrap(); + live.swap_remove(pos); + } + 4 if !live.is_empty() => { + let want = extreme(&live, true).unwrap(); + let it = heap.pop_back().unwrap(); + let got = (it.key, it.label); + assert_eq!( + got, + (want.0, format!("s{}", want.1)), + "pop_back at step {step}" + ); + let pos = live.iter().position(|(_, s)| *s == want.1).unwrap(); + live.swap_remove(pos); + } + 5 if !live.is_empty() => { + let (c, _) = live.swap_remove(rng.usize(0..live.len())); + let key = key_of(&c); + assert_eq!(heap.remove(c).key, key); + } + 6 if !live.is_empty() => { + // lazy increase only + let (mut c, _) = live[rng.usize(0..live.len())]; + unsafe { c.element_mut().key += rng.u32(0..5) }; + heap.mark_unsorted(); + } + 7 if !live.is_empty() => { + // arbitrary change, possibly a decrease + let (mut c, _) = live[rng.usize(0..live.len())]; + unsafe { c.element_mut().key = rng.u32(0..20) }; + heap.mark_changed(c); + } + _ => { + let mut sorted: Vec<_> = live.iter().map(|(c, s)| (key_of(c), *s)).collect(); + sorted.sort(); + let got: Vec<_> = heap + .iter() + .map(|c| { + ( + key_of(&c), + live.iter() + .find(|(l, _)| l.as_ptr() == c.as_ptr()) + .unwrap() + .1, + ) + }) + .collect(); + assert_eq!(got, sorted, "iter at step {step}"); + } + } + assert_eq!(heap.len(), live.len()); + check(&heap); + } + } +} diff --git a/src/internal/linked_list.rs b/src/internal/linked_list.rs index 06487e6..2957b1a 100644 --- a/src/internal/linked_list.rs +++ b/src/internal/linked_list.rs @@ -659,7 +659,9 @@ unsafe impl Sync for Cursor {} #[cfg(test)] mod tests { - use super::LinkedList; + use std::collections::VecDeque; + + use super::*; #[test] fn a_panicking_element_drop_still_frees_every_node() { @@ -688,4 +690,163 @@ mod tests { // the other elements, the free list and the sentinel are freed; under // Miri this also checks that nothing leaks } + + fn values(list: &LinkedList) -> Vec { + unsafe { list.iter().map(|c| c.element().clone()).collect() } + } + + #[test] + fn push_pop_move_unlink_iter() { + let mut list = LinkedList::new(); + let a = list.push_back("a".to_string()); + let b = list.push_back("b".to_string()); + let _c = list.push_front("c".to_string()); + assert_eq!(values(&list), ["c", "a", "b"]); + check(&list); + + unsafe { + b.move_to_front(&mut list); + a.move_to_back(&mut list); + assert!(a.is_back(&list)); + } + assert_eq!(values(&list), ["b", "c", "a"]); + check(&list); + + assert_eq!(unsafe { b.unlink(&mut list) }, "b"); + assert_eq!(list.pop_front().as_deref(), Some("c")); + // freed nodes are reused + list.push_front("d".to_string()); + list.push_back("e".to_string()); + assert_eq!(values(&list), ["d", "a", "e"]); + assert_eq!(list.pop_back().as_deref(), Some("e")); + assert_eq!(list.len(), 2); + check(&list); + + list.clear(); + assert!(list.is_empty()); + check(&list); + list.push_back("f".to_string()); + // dropped with one element and a free list: no leak, no double free + } + + /// Both directions walk exactly `len` nodes with consistent back links, the + /// free list holds `free_len` nodes (at most the cap), and no node is both + /// linked and free. + fn check(list: &LinkedList) { + let s = list.sentinel.as_ptr(); + let mut linked = Vec::new(); + let mut node = unsafe { (*s).next }; + while node != s { + assert_eq!(unsafe { (*(*node).next).prev }, node, "broken back link"); + linked.push(node); + node = unsafe { (*node).next }; + } + assert_eq!(linked.len(), list.len, "forward walk length"); + + let mut back = 0; + let mut node = unsafe { (*s).prev }; + while node != s { + back += 1; + node = unsafe { (*node).prev }; + } + assert_eq!(back, list.len, "backward walk length"); + + let mut free = 0; + let mut node = list.free_head; + while !node.is_null() { + assert!(!linked.contains(&node), "node both linked and free"); + free += 1; + node = unsafe { (*node).next }; + } + assert_eq!(free, list.free_len); + assert!(list.free_len <= FREE_LIST_CAPACITY); + } + + #[test] + fn empty_and_single_element_edges() { + let mut list: LinkedList = LinkedList::new(); + assert!(list.cursor_front().is_none() && list.cursor_back().is_none()); + assert!(list.pop_front().is_none() && list.pop_back().is_none()); + assert_eq!(unsafe { list.iter() }.count(), 0); + check(&list); + + let a = list.push_back("a".to_string()); + assert!(list.cursor_front() == Some(a) && list.cursor_back() == Some(a)); + unsafe { + assert!(a.is_back(&list)); + a.move_to_front(&mut list); // already the front + a.move_to_back(&mut list); // already the back + } + check(&list); + assert_eq!(values(&list), ["a"]); + assert_eq!(unsafe { a.unlink(&mut list) }, "a"); + assert!(list.is_empty()); + check(&list); + } + + #[test] + fn free_list_is_capped_and_reused() { + let mut list = LinkedList::new(); + for i in 0..FREE_LIST_CAPACITY + 50 { + list.push_back(i.to_string()); + } + list.clear(); + check(&list); + assert_eq!(list.free_len, FREE_LIST_CAPACITY, "extra nodes are freed"); + + for i in 0..10 { + list.push_front(i.to_string()); + } + assert_eq!(list.free_len, FREE_LIST_CAPACITY - 10, "free nodes reused"); + check(&list); + // dropped with linked and free nodes: Miri reports any leak + } + + /// Random operations against a `VecDeque` model of the element order. + #[test] + fn random_operations_match_a_model() { + let mut rng = fastrand::Rng::with_seed(0x1157); + let mut list = LinkedList::new(); + let mut model: VecDeque<(Cursor, String)> = VecDeque::new(); + + for step in 0..400 { + let label = format!("s{step}"); + match rng.u8(0..9) { + 0 | 1 => model.push_back((list.push_back(label.clone()), label)), + 2 | 3 => model.push_front((list.push_front(label.clone()), label)), + 4 => assert_eq!(list.pop_front(), model.pop_front().map(|(_, l)| l)), + 5 => assert_eq!(list.pop_back(), model.pop_back().map(|(_, l)| l)), + 6 if !model.is_empty() => { + let (c, l) = model.remove(rng.usize(0..model.len())).unwrap(); + assert_eq!(unsafe { c.unlink(&mut list) }, l); + } + 7 if !model.is_empty() => { + let entry = model.remove(rng.usize(0..model.len())).unwrap(); + if rng.bool() { + unsafe { entry.0.move_to_front(&mut list) }; + model.push_front(entry); + } else { + unsafe { entry.0.move_to_back(&mut list) }; + model.push_back(entry); + } + } + 8 if rng.u8(0..20) == 0 => { + list.clear(); + model.clear(); + } + _ => {} + } + let want: Vec<_> = model.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!(values(&list), want, "order at step {step}"); + assert!( + list.cursor_front() == model.front().map(|(c, _)| *c), + "front at step {step}" + ); + assert!( + list.cursor_back() == model.back().map(|(c, _)| *c), + "back at step {step}" + ); + check(&list); + } + } } From a838c9605a4c1bff60d0777dc58def8d9ae5e608 Mon Sep 17 00:00:00 2001 From: Roman Varnava Date: Sat, 31 Oct 2026 16:44:47 +0300 Subject: [PATCH 2/2] ci: run the Rust unit tests natively and under Miri cargo test on stable, and cargo miri test on nightly to check the unsafe code in hashbrown, LinkedList and LazyHeap for undefined behaviour and leaks. --- .github/workflows/rust-test.yml | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/rust-test.yml diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml new file mode 100644 index 0000000..7bb7322 --- /dev/null +++ b/.github/workflows/rust-test.yml @@ -0,0 +1,55 @@ +name: Rust Test + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rust-tests-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: 1 + +jobs: + cargo-test: + name: cargo test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable # zizmor: ignore[unpinned-uses] + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + + - name: "Run cargo test" + run: cargo test --no-default-features --lib + + miri: + name: miri + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@nightly # zizmor: ignore[unpinned-uses] + with: + components: miri, rust-src + + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.13" + + - name: "Run cargo miri test" + # rust-toolchain.toml pins stable; Miri needs nightly + run: cargo +nightly miri test --no-default-features --lib