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 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); + } + } }