use std::marker::PhantomData;
use crate::core::{NodeState, Pending};
mod sealed {
pub trait Sealed {}
}
#[doc(hidden)]
pub trait Lifecycle: sealed::Sealed {}
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct LiveNonZero;
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct LiveZero;
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct Orphan;
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct Released;
impl sealed::Sealed for LiveNonZero {}
impl sealed::Sealed for LiveZero {}
impl sealed::Sealed for Orphan {}
impl sealed::Sealed for Released {}
impl Lifecycle for LiveNonZero {}
impl Lifecycle for LiveZero {}
impl Lifecycle for Orphan {}
impl Lifecycle for Released {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub(crate) enum ResidentLifecycle {
LiveNonZero,
LiveZero,
Orphan,
}
impl ResidentLifecycle {
pub(crate) fn classify(s: &NodeState) -> Self {
match s {
NodeState::Live { open_count: 0 } => Self::LiveZero,
NodeState::Live { .. } => Self::LiveNonZero,
NodeState::Orphan { .. } => Self::Orphan,
}
}
}
#[derive(Debug)]
#[doc(hidden)]
pub struct Witness<'p, 'brand, S: Lifecycle> {
id: u64,
_state: PhantomData<S>,
_borrow: PhantomData<&'p mut ()>,
_brand: PhantomData<fn(&'brand ()) -> &'brand ()>,
_not_send: PhantomData<*const ()>,
}
impl<'p, 'brand, S: Lifecycle> Witness<'p, 'brand, S> {
fn new(id: u64) -> Self {
Self {
id,
_state: PhantomData,
_borrow: PhantomData,
_brand: PhantomData,
_not_send: PhantomData,
}
}
#[doc(hidden)]
pub fn id(&self) -> u64 {
self.id
}
}
impl<'a> Pending<'a> {
#[doc(hidden)]
pub fn with_brand<R>(
&mut self,
f: impl for<'brand> FnOnce(&mut BrandedPending<'_, 'brand>) -> R,
) -> R {
let rebranded: &mut Pending<'_> =
unsafe { std::mem::transmute::<&mut Pending<'a>, &mut Pending<'_>>(self) };
let mut bp = BrandedPending { inner: rebranded };
f(&mut bp)
}
pub(crate) fn drain_for_capture(&mut self) {
let surviving: std::collections::BTreeSet<u64> = self
.lifecycle_iter()
.filter_map(|(id, s)| match ResidentLifecycle::classify(&s) {
ResidentLifecycle::LiveNonZero => Some(id), ResidentLifecycle::Orphan => Some(id), ResidentLifecycle::LiveZero => None, })
.collect();
self.apply_drain_for_capture(&surviving);
}
}
#[doc(hidden)]
pub struct BrandedPending<'p, 'brand> {
inner: &'p mut Pending<'brand>,
}
impl<'p, 'brand> BrandedPending<'p, 'brand> {
#[doc(hidden)]
pub fn peek_witness<S: Lifecycle>(&self, w: &Witness<'_, 'brand, S>) -> u64 {
let _ = self;
w.id()
}
}
#[derive(Debug)]
#[doc(hidden)]
pub struct KernelForgetWitness<'p, 'brand> {
id: u64,
_borrow: PhantomData<&'p mut ()>,
_brand: PhantomData<fn(&'brand ()) -> &'brand ()>,
_not_send: PhantomData<*const ()>,
}
impl<'p, 'brand> KernelForgetWitness<'p, 'brand> {
fn new(id: u64) -> Self {
Self {
id,
_borrow: PhantomData,
_brand: PhantomData,
_not_send: PhantomData,
}
}
#[doc(hidden)]
pub fn id(&self) -> u64 {
self.id
}
}
impl<'p, 'brand> BrandedPending<'p, 'brand> {
#[doc(hidden)]
pub fn witness_live_nonzero(&mut self, id: u64) -> Option<Witness<'_, 'brand, LiveNonZero>> {
match self.inner.lookup_state(id) {
Some(NodeState::Live { open_count }) if open_count >= 1 => Some(Witness::new(id)),
_ => None,
}
}
#[doc(hidden)]
pub(crate) fn transition_to_orphan<'a>(
&'a mut self,
id: u64,
) -> Option<Witness<'a, 'brand, Orphan>> {
match self.inner.lookup_state(id) {
Some(NodeState::Live { open_count }) if open_count >= 1 => {
let w = Witness::<'a, 'brand, Orphan>::new(id);
self.inner.apply_transition_to_orphan(&w);
Some(w)
}
_ => None,
}
}
#[doc(hidden)]
pub fn witness_kernel_forget(&mut self, id: u64) -> Option<KernelForgetWitness<'_, 'brand>> {
match self.inner.lookup_state(id) {
None => Some(KernelForgetWitness::new(id)),
Some(NodeState::Live { open_count: 0 }) => Some(KernelForgetWitness::new(id)),
_ => None,
}
}
#[doc(hidden)]
pub(crate) fn kernel_forget_inode(&mut self, id: u64) -> Option<bool> {
match self.inner.lookup_state(id) {
None | Some(NodeState::Live { open_count: 0 }) => {
let w = KernelForgetWitness::<'_, 'brand>::new(id);
Some(self.inner.apply_kernel_forget(&w))
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn witness_live_nonzero_some_for_live_with_open() {
let mut p = Pending::default();
p.test_insert_state(7, NodeState::Live { open_count: 1 });
let id = p
.with_brand(|bp| bp.witness_live_nonzero(7).map(|w| w.id()))
.expect("LiveNonZero witness");
assert_eq!(id, 7);
}
#[test]
fn witness_live_nonzero_some_for_high_refcount() {
let mut p = Pending::default();
p.test_insert_state(
11,
NodeState::Live {
open_count: u32::MAX,
},
);
let id = p
.with_brand(|bp| bp.witness_live_nonzero(11).map(|w| w.id()))
.expect("LiveNonZero witness");
assert_eq!(id, 11);
}
#[test]
fn witness_live_nonzero_none_for_live_zero() {
let mut p = Pending::default();
p.test_insert_state(7, NodeState::Live { open_count: 0 });
assert!(!p.with_brand(|bp| bp.witness_live_nonzero(7).is_some()));
}
#[test]
fn witness_live_nonzero_none_for_orphan() {
let mut p = Pending::default();
p.test_insert_state(7, NodeState::Orphan { open_count: 3 });
assert!(!p.with_brand(|bp| bp.witness_live_nonzero(7).is_some()));
}
#[test]
fn witness_live_nonzero_none_for_released() {
let mut p = Pending::default();
assert!(!p.with_brand(|bp| bp.witness_live_nonzero(7).is_some()));
}
#[test]
fn witness_kernel_forget_some_for_released() {
let mut p = Pending::default();
let id = p
.with_brand(|bp| bp.witness_kernel_forget(31).map(|w| w.id()))
.expect("KernelForget witness");
assert_eq!(id, 31);
}
#[test]
fn witness_kernel_forget_some_for_live_zero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Live { open_count: 0 });
let id = p
.with_brand(|bp| bp.witness_kernel_forget(31).map(|w| w.id()))
.expect("KernelForget witness");
assert_eq!(id, 31);
}
#[test]
fn witness_kernel_forget_none_for_live_nonzero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Live { open_count: 1 });
assert!(!p.with_brand(|bp| bp.witness_kernel_forget(31).is_some()));
}
#[test]
fn witness_kernel_forget_none_for_orphan_zero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Orphan { open_count: 0 });
assert!(!p.with_brand(|bp| bp.witness_kernel_forget(31).is_some()));
}
#[test]
fn witness_kernel_forget_none_for_orphan_nonzero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Orphan { open_count: 5 });
assert!(!p.with_brand(|bp| bp.witness_kernel_forget(31).is_some()));
}
const _ASSERT_NOT_SEND: () = {
trait AmbiguousIfSend<A> {
fn some() {}
}
impl<T: ?Sized> AmbiguousIfSend<()> for T {}
#[allow(dead_code)]
struct Invalid;
impl<T: ?Sized + Send> AmbiguousIfSend<Invalid> for T {}
let _ = <Witness<'static, 'static, LiveNonZero> as AmbiguousIfSend<_>>::some;
let _ = <Witness<'static, 'static, LiveZero> as AmbiguousIfSend<_>>::some;
let _ = <Witness<'static, 'static, Orphan> as AmbiguousIfSend<_>>::some;
let _ = <Witness<'static, 'static, Released> as AmbiguousIfSend<_>>::some;
let _ = <KernelForgetWitness<'static, 'static> as AmbiguousIfSend<_>>::some;
};
const _ASSERT_NOT_SYNC: () = {
trait AmbiguousIfSync<A> {
fn some() {}
}
impl<T: ?Sized> AmbiguousIfSync<()> for T {}
#[allow(dead_code)]
struct Invalid;
impl<T: ?Sized + Sync> AmbiguousIfSync<Invalid> for T {}
let _ = <Witness<'static, 'static, LiveNonZero> as AmbiguousIfSync<_>>::some;
let _ = <Witness<'static, 'static, LiveZero> as AmbiguousIfSync<_>>::some;
let _ = <Witness<'static, 'static, Orphan> as AmbiguousIfSync<_>>::some;
let _ = <Witness<'static, 'static, Released> as AmbiguousIfSync<_>>::some;
let _ = <KernelForgetWitness<'static, 'static> as AmbiguousIfSync<_>>::some;
};
#[test]
fn with_brand_threads_a_fresh_brand_through_witness_construction() {
let mut p = Pending::default();
p.test_insert_state(7, NodeState::Live { open_count: 1 });
let id = p
.with_brand(|bp| {
bp.witness_live_nonzero(7).map(|w| w.id())
})
.expect("LiveNonZero witness");
assert_eq!(id, 7);
}
#[test]
fn brand_round_trips_independently_across_two_pendings() {
let mut p1 = Pending::default();
let mut p2 = Pending::default();
p1.test_insert_state(11, NodeState::Live { open_count: 2 });
p2.test_insert_state(13, NodeState::Live { open_count: 1 });
let id1 = p1
.with_brand(|bp| bp.witness_live_nonzero(11).map(|w| w.id()))
.expect("p1 LiveNonZero witness");
let id2 = p2
.with_brand(|bp| bp.witness_live_nonzero(13).map(|w| w.id()))
.expect("p2 LiveNonZero witness");
assert_eq!(id1, 11);
assert_eq!(id2, 13);
}
#[test]
fn with_brand_can_return_unbranded_node_id() {
let mut p = Pending::default();
p.test_insert_state(17, NodeState::Live { open_count: 3 });
let extracted: u64 = p.with_brand(|bp| {
bp.witness_live_nonzero(17).map(|w| w.id()).unwrap_or(0)
});
assert_eq!(extracted, 17);
}
#[test]
fn drain_for_capture_preserves_live_nonzero_state() {
let mut p = Pending::default();
p.test_insert_state(7, NodeState::Live { open_count: 1 });
p.drain_for_capture();
assert_eq!(
p.lookup_state(7),
Some(NodeState::Live { open_count: 1 }),
"LiveNonZero must survive capture (POSIX last-close-wins; r11 #2)"
);
}
#[test]
fn drain_for_capture_preserves_live_nonzero_with_high_refcount() {
let mut p = Pending::default();
p.test_insert_state(
11,
NodeState::Live {
open_count: u32::MAX,
},
);
p.drain_for_capture();
assert_eq!(
p.lookup_state(11),
Some(NodeState::Live {
open_count: u32::MAX
}),
"LiveNonZero entries must carry their open_count across capture"
);
}
#[test]
fn drain_for_capture_preserves_live_nonzero_hot_bytes() {
let mut p = Pending::default();
p.test_insert_state(13, NodeState::Live { open_count: 2 });
p.test_insert_hot(13, std::path::PathBuf::from("file.txt"), b"BYTES".to_vec());
p.drain_for_capture();
assert!(
p.test_has_hot(13),
"hot[id] for a LiveNonZero entry must survive capture so reads via the open fd serve the buffered bytes"
);
}
#[test]
fn drain_for_capture_drops_live_zero_state() {
let mut p = Pending::default();
p.test_insert_state(9, NodeState::Live { open_count: 0 });
p.drain_for_capture();
assert!(
p.lookup_state(9).is_none(),
"LiveZero entries (no open fds) must be retired by capture; the new tree owns their data"
);
}
#[test]
fn drain_for_capture_drops_live_zero_hot_bytes() {
let mut p = Pending::default();
p.test_insert_state(9, NodeState::Live { open_count: 0 });
p.test_insert_hot(9, std::path::PathBuf::from("x.txt"), b"X".to_vec());
p.drain_for_capture();
assert!(
!p.test_has_hot(9),
"hot[id] for a LiveZero entry should be dropped alongside its state row"
);
}
#[test]
fn drain_for_capture_preserves_orphan_state() {
let mut p = Pending::default();
p.test_insert_state(21, NodeState::Orphan { open_count: 3 });
p.drain_for_capture();
assert_eq!(
p.lookup_state(21),
Some(NodeState::Orphan { open_count: 3 }),
"Orphan entries must survive capture (open-but-unlinked POSIX)"
);
}
#[test]
fn drain_for_capture_preserves_orphan_with_zero_refcount() {
let mut p = Pending::default();
p.test_insert_state(23, NodeState::Orphan { open_count: 0 });
p.drain_for_capture();
assert_eq!(
p.lookup_state(23),
Some(NodeState::Orphan { open_count: 0 }),
"Orphan with zero refcount must survive capture; the discriminant — not the count — is what matters"
);
}
#[test]
fn drain_for_capture_preserves_orphan_hot_bytes() {
let mut p = Pending::default();
p.test_insert_state(25, NodeState::Orphan { open_count: 1 });
p.test_insert_hot(
25,
std::path::PathBuf::from("gone.txt"),
b"SURVIVES".to_vec(),
);
p.drain_for_capture();
assert!(
p.test_has_hot(25),
"hot[id] for an Orphan entry must survive capture so the surviving fd serves the inode's own bytes"
);
}
#[test]
fn drain_for_capture_mixed_state_map() {
let mut p = Pending::default();
p.test_insert_state(100, NodeState::Live { open_count: 1 }); p.test_insert_state(101, NodeState::Live { open_count: 0 }); p.test_insert_state(102, NodeState::Orphan { open_count: 2 }); p.test_insert_state(103, NodeState::Orphan { open_count: 0 }); p.test_insert_state(104, NodeState::Live { open_count: 7 });
p.drain_for_capture();
assert_eq!(p.lookup_state(100), Some(NodeState::Live { open_count: 1 }));
assert!(p.lookup_state(101).is_none(), "LiveZero must retire");
assert_eq!(
p.lookup_state(102),
Some(NodeState::Orphan { open_count: 2 })
);
assert_eq!(
p.lookup_state(103),
Some(NodeState::Orphan { open_count: 0 })
);
assert_eq!(p.lookup_state(104), Some(NodeState::Live { open_count: 7 }));
}
#[test]
fn kernel_forget_inode_some_for_released_no_warm() {
let mut p = Pending::default();
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(31));
assert_eq!(
outcome,
Some(false),
"Released + no warm → discharge ran, inode record retires"
);
}
#[test]
fn kernel_forget_inode_some_for_live_zero_no_warm() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Live { open_count: 0 });
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(31));
assert_eq!(
outcome,
Some(false),
"LiveZero + no warm → discharge ran, inode record retires"
);
assert!(
p.lookup_state(31).is_none(),
"discharge must drop state[id] for LiveZero"
);
}
#[test]
fn kernel_forget_inode_drops_hot_bytes_for_live_zero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Live { open_count: 0 });
p.test_insert_hot(31, std::path::PathBuf::from("x.txt"), b"X".to_vec());
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(31));
assert_eq!(outcome, Some(false));
assert!(
!p.test_has_hot(31),
"hot[id] for a LiveZero entry must be dropped by the discharge"
);
}
#[test]
fn kernel_forget_inode_none_for_live_nonzero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Live { open_count: 1 });
p.test_insert_hot(31, std::path::PathBuf::from("x.txt"), b"X".to_vec());
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(31));
assert_eq!(outcome, None, "LiveNonZero must reject the discharge");
assert_eq!(
p.lookup_state(31),
Some(NodeState::Live { open_count: 1 }),
"state[id] must be preserved when the witness rejects"
);
assert!(
p.test_has_hot(31),
"hot[id] must be preserved when the witness rejects \
(otherwise the open fd loses its bytes)"
);
}
#[test]
fn kernel_forget_inode_none_for_orphan_nonzero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Orphan { open_count: 1 });
p.test_insert_hot(31, std::path::PathBuf::from("gone.txt"), b"BYTES".to_vec());
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(31));
assert_eq!(outcome, None, "Orphan with open fds must reject");
assert_eq!(
p.lookup_state(31),
Some(NodeState::Orphan { open_count: 1 }),
"Orphan state must be preserved when the witness rejects"
);
assert!(
p.test_has_hot(31),
"hot[id] for an Orphan with open fds must outlive a kernel \
forget — the surviving fd needs the bytes (r11 #3)"
);
}
#[test]
fn kernel_forget_inode_none_for_orphan_zero() {
let mut p = Pending::default();
p.test_insert_state(31, NodeState::Orphan { open_count: 0 });
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(31));
assert_eq!(
outcome, None,
"Orphan with zero refcount must still reject the discharge"
);
assert_eq!(
p.lookup_state(31),
Some(NodeState::Orphan { open_count: 0 }),
"Orphan state must be preserved when the witness rejects"
);
}
#[test]
fn kernel_forget_inode_short_circuits_state_and_hot_when_none() {
let mut p = Pending::default();
p.test_insert_state(42, NodeState::Live { open_count: 3 });
p.test_insert_hot(42, std::path::PathBuf::from("live.txt"), b"L".to_vec());
let outcome = p.with_brand(|bp| bp.kernel_forget_inode(42));
assert_eq!(outcome, None);
assert_eq!(
p.lookup_state(42),
Some(NodeState::Live { open_count: 3 }),
"state[id] untouched on short-circuit"
);
assert!(p.test_has_hot(42), "hot[id] untouched on short-circuit");
}
#[test]
fn resident_lifecycle_classify_live_zero() {
assert_eq!(
ResidentLifecycle::classify(&NodeState::Live { open_count: 0 }),
ResidentLifecycle::LiveZero,
);
}
#[test]
fn resident_lifecycle_classify_live_nonzero_at_one() {
assert_eq!(
ResidentLifecycle::classify(&NodeState::Live { open_count: 1 }),
ResidentLifecycle::LiveNonZero,
);
}
#[test]
fn resident_lifecycle_classify_live_nonzero_at_max() {
assert_eq!(
ResidentLifecycle::classify(&NodeState::Live {
open_count: u32::MAX
}),
ResidentLifecycle::LiveNonZero,
);
}
#[test]
fn resident_lifecycle_classify_orphan_any_refcount() {
assert_eq!(
ResidentLifecycle::classify(&NodeState::Orphan { open_count: 0 }),
ResidentLifecycle::Orphan,
);
assert_eq!(
ResidentLifecycle::classify(&NodeState::Orphan { open_count: 5 }),
ResidentLifecycle::Orphan,
);
assert_eq!(
ResidentLifecycle::classify(&NodeState::Orphan {
open_count: u32::MAX
}),
ResidentLifecycle::Orphan,
);
}
use proptest::prelude::*;
proptest::proptest! {
#[test]
fn drain_for_capture_proptest_preserves_open_counts(
entries in proptest::collection::vec(
(
0u64..16,
proptest::prop_oneof![
proptest::num::u32::ANY
.prop_map(|n| NodeState::Live { open_count: n }),
proptest::num::u32::ANY
.prop_map(|n| NodeState::Orphan { open_count: n }),
],
),
0..32usize,
),
) {
let mut p = Pending::default();
let mut expected: std::collections::BTreeMap<u64, NodeState> =
std::collections::BTreeMap::new();
for (id, state) in &entries {
p.test_insert_state(*id, *state);
expected.insert(*id, *state);
}
expected.retain(|_, s| !matches!(s, NodeState::Live { open_count: 0 }));
p.drain_for_capture();
let after: std::collections::BTreeMap<u64, NodeState> =
p.lifecycle_iter().collect();
proptest::prop_assert_eq!(after, expected);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelState {
Released,
Live(u32),
Orphan(u32),
}
#[derive(Clone, Debug)]
enum Op {
Open(u8),
Release(u8),
Unlink(u8),
Forget(u8),
Drain,
}
#[derive(Debug, Default, Clone)]
struct Oracle {
states: std::collections::BTreeMap<u8, ModelState>,
}
impl Oracle {
fn lookup(&self, id: u8) -> ModelState {
self.states
.get(&id)
.copied()
.unwrap_or(ModelState::Released)
}
fn set(&mut self, id: u8, state: ModelState) {
if matches!(state, ModelState::Released) {
self.states.remove(&id);
} else {
self.states.insert(id, state);
}
}
fn apply(&mut self, op: &Op) {
match op {
Op::Open(id) => {
let next = match self.lookup(*id) {
ModelState::Released => ModelState::Live(1),
ModelState::Live(n) => ModelState::Live(n.saturating_add(1)),
ModelState::Orphan(n) => ModelState::Orphan(n.saturating_add(1)),
};
self.set(*id, next);
}
Op::Release(id) => {
let next = match self.lookup(*id) {
ModelState::Released => ModelState::Released,
ModelState::Live(n) => ModelState::Live(n.saturating_sub(1)),
ModelState::Orphan(n) => ModelState::Orphan(n.saturating_sub(1)),
};
self.set(*id, next);
}
Op::Unlink(id) => {
if let ModelState::Live(n) = self.lookup(*id)
&& n >= 1
{
self.set(*id, ModelState::Orphan(n));
}
}
Op::Forget(id) => {
match self.lookup(*id) {
ModelState::Released | ModelState::Live(0) => {
self.set(*id, ModelState::Released);
}
_ => { }
}
}
Op::Drain => {
self.states.retain(|_, s| !matches!(s, ModelState::Live(0)));
}
}
}
}
fn apply_to_pending(p: &mut Pending<'_>, op: &Op) {
match op {
Op::Open(id) => {
let id64 = *id as u64;
let next = match p.lookup_state(id64) {
None => NodeState::Live { open_count: 1 },
Some(NodeState::Live { open_count }) => NodeState::Live {
open_count: open_count.saturating_add(1),
},
Some(NodeState::Orphan { open_count }) => NodeState::Orphan {
open_count: open_count.saturating_add(1),
},
};
p.test_insert_state(id64, next);
}
Op::Release(id) => {
let id64 = *id as u64;
if let Some(s) = p.lookup_state(id64) {
let next = match s {
NodeState::Live { open_count } => NodeState::Live {
open_count: open_count.saturating_sub(1),
},
NodeState::Orphan { open_count } => NodeState::Orphan {
open_count: open_count.saturating_sub(1),
},
};
p.test_insert_state(id64, next);
}
}
Op::Unlink(id) => {
p.with_brand(|bp| {
let _ = bp.transition_to_orphan(*id as u64);
});
}
Op::Forget(id) => {
p.with_brand(|bp| {
let _ = bp.kernel_forget_inode(*id as u64);
});
}
Op::Drain => {
p.drain_for_capture();
}
}
}
fn project(s: Option<NodeState>) -> ModelState {
match s {
None => ModelState::Released,
Some(NodeState::Live { open_count }) => ModelState::Live(open_count),
Some(NodeState::Orphan { open_count }) => ModelState::Orphan(open_count),
}
}
fn op_strategy() -> impl Strategy<Value = Op> {
proptest::prop_oneof![
(0u8..8u8).prop_map(Op::Open),
(0u8..8u8).prop_map(Op::Release),
(0u8..8u8).prop_map(Op::Unlink),
(0u8..8u8).prop_map(Op::Forget),
proptest::strategy::Just(Op::Drain),
]
}
proptest::proptest! {
#[test]
fn fsm_state_matches_oracle(
ops in proptest::collection::vec(op_strategy(), 0..32),
) {
let mut p = Pending::default();
let mut oracle = Oracle::default();
for op in &ops {
apply_to_pending(&mut p, op);
oracle.apply(op);
}
for id in 0u8..8u8 {
let want = oracle.lookup(id);
let got = project(p.lookup_state(id as u64));
proptest::prop_assert_eq!(
want, got,
"FSM divergence at id {} after {:?}", id, ops
);
}
}
#[test]
fn fsm_open_count_refcount_balance(
ops in proptest::collection::vec(op_strategy(), 0..32),
) {
let mut p = Pending::default();
let mut oracle = Oracle::default();
for op in &ops {
apply_to_pending(&mut p, op);
oracle.apply(op);
}
for (id_u64, state) in p.lifecycle_iter() {
let id = id_u64 as u8;
let want = match oracle.lookup(id) {
ModelState::Live(n) | ModelState::Orphan(n) => n,
ModelState::Released => {
return Err(proptest::test_runner::TestCaseError::fail(
format!("id {id} survives in pending but oracle says Released")
));
}
};
let got = match state {
NodeState::Live { open_count } | NodeState::Orphan { open_count } => open_count,
};
proptest::prop_assert_eq!(want, got, "open_count divergence at id {}", id);
}
}
}
#[test]
fn harness_catches_state_divergence() {
let mut p = Pending::default();
p.test_insert_state(0, NodeState::Live { open_count: 1 });
let mut oracle = Oracle::default();
oracle.apply(&Op::Open(0));
oracle.apply(&Op::Unlink(0));
assert_eq!(oracle.lookup(0), ModelState::Orphan(1));
assert_eq!(
project(p.lookup_state(0)),
ModelState::Live(1),
"harness setup precondition"
);
let oracle_state = oracle.lookup(0);
let pending_state = project(p.lookup_state(0));
assert_ne!(
oracle_state, pending_state,
"harness must report divergence: oracle says {oracle_state:?}, \
pending says {pending_state:?}"
);
}
#[test]
fn release_saturates_at_zero_under_unbalanced_stream() {
let mut p = Pending::default();
let ops = [
Op::Open(0),
Op::Release(0),
Op::Release(0), Op::Release(0),
];
let mut oracle = Oracle::default();
for op in &ops {
apply_to_pending(&mut p, op);
oracle.apply(op);
}
assert_eq!(oracle.lookup(0), ModelState::Live(0));
assert_eq!(project(p.lookup_state(0)), ModelState::Live(0));
}
}