diff options
Diffstat (limited to 'crates/tor-memquota/src/mtracker')
| -rw-r--r-- | crates/tor-memquota/src/mtracker/bookkeeping.rs | 308 | ||||
| -rw-r--r-- | crates/tor-memquota/src/mtracker/reclaim.rs | 396 | ||||
| -rw-r--r-- | crates/tor-memquota/src/mtracker/reclaim/deferred_drop.rs | 81 | ||||
| -rw-r--r-- | crates/tor-memquota/src/mtracker/test.rs | 678 | ||||
| -rw-r--r-- | crates/tor-memquota/src/mtracker/total_qty_notifier.rs | 70 |
5 files changed, 1533 insertions, 0 deletions
diff --git a/crates/tor-memquota/src/mtracker/bookkeeping.rs b/crates/tor-memquota/src/mtracker/bookkeeping.rs new file mode 100644 index 000000000..ec1241196 --- /dev/null +++ b/crates/tor-memquota/src/mtracker/bookkeeping.rs @@ -0,0 +1,308 @@ +//! Quantity bookkeeping +//! +//! Newtypes which wrap up a `Qty` (an amount of memory), +//! and which assure proper accounting. +//! +//! Methods are provided for the specific transactions which are correct, +//! in the accounting scheme in [`tracker`](super). +//! So these types embody the data structure (fields and invariants) from `tracker`. +//! +//! # Panics +//! +//! In tests, these types panic if they are dropped when nonzero, +//! if that's against the rules. + +use super::*; + +define_derive_deftly! { + /// Implement [`BookkeptQty`] and its supertraits + /// + /// By default, dropping when nonzero is forbidden, + /// and you must have a field `bomb: `[`DropBombCondition`]. + /// `#[deftly(allow_nonzero_drop)]` suppresses this. + BookkeptQty = + + ${defcond BOMB not(tmeta(allow_nonzero_drop))} + + impl BookkeepableQty for $ttype { + const ZERO: $ttype = $ttype { + raw: Qty(0), + ${if BOMB { + bomb: DropBombCondition::new_armed(), + }} + }; + + fn as_raw(&self) -> Qty { + self.raw + } + } + + impl<Rhs: BookkeepableQty> PartialEq<Rhs> for $ttype { + fn eq(&self, other: &Rhs) -> bool { + self.as_raw().eq(&other.as_raw()) + } + } + impl<Rhs: BookkeepableQty> PartialOrd<Rhs> for $ttype { + fn partial_cmp(&self, other: &Rhs) -> Option<Ordering> { + self.as_raw().partial_cmp(&other.as_raw()) + } + } + + impl DefaultExtTake for $ttype {} + + impl BookkeptQty for $ttype { + fn from_raw(q: Qty) -> Self { + $ttype { + raw: q, + ${if BOMB { + bomb: DropBombCondition::new_armed(), + }} + } + } + fn into_raw(mut self) -> Qty { + mem::replace(&mut self.raw, Qty(0)) + } + } + + assert_not_impl_any!($ttype: Clone, Into<Qty>, From<Qty>); + + ${if BOMB { + #[cfg(test)] + impl Drop for $ttype { + fn drop(&mut self) { + drop_bomb_disarm_assert!(self.bomb, self.raw == Qty(0)); + } + } + }} +} + +/// Memory quantities that can work with bookkept quantities +/// +/// This trait doesn't imply any invariants; +/// it merely provides read-only access to the underlying value, +/// and ways to make a zero. +/// +/// Used by the derived `PartialEq` and `PartialOrd` impls on bookkept quantities. +/// +/// Implemented by hand for `Qty`. +/// +/// Implemented for bookkept types, along with `BookkeptQty`, by +/// [`#[derive_deftly(BookKept)]`](derive_deftly_template_BookkeptQty). +pub(super) trait BookkeepableQty: Default { + /// Zero (default value) + const ZERO: Self; + + /// Inspect as a raw untracked Qty + fn as_raw(&self) -> Qty; +} + +/// Bookkept memory quantities +/// +/// Each bookkept quantity implements this trait, +/// and has a single field `raw` of type `Qty`. +/// +/// Should be Implemented by +/// [`#[derive_deftly(BookKept)]`](derive_deftly_template_BookkeptQty) +/// and for raw `Qty`. +/// +/// # CORRECTNESS +/// +/// All accesses to `raw`, or calls to `from_raw` or `into_raw`, +/// should be made from transaction functions, +/// which modify one or more bookkept quantities together, +/// preserving the invariants. +/// +/// `raw` may be accessed mutably by such functions, but a bookkept quantity type +/// should be constructed only with `from_raw` and should not be moved out of. +trait BookkeptQty: BookkeepableQty + DefaultExtTake { + /// Make a new bookkept quantity from a raw untracked Qty + /// + fn from_raw(q: Qty) -> Self; + + /// Unwrap into a raw untracked Qty + fn into_raw(self) -> Qty; +} + +impl BookkeepableQty for Qty { + const ZERO: Qty = Qty(0); + + fn as_raw(&self) -> Qty { + *self + } +} + +/// Total used, [`TotalQtyNotifier`].`total_used`, found in [`State`].`total_used`. +/// +/// Can be "poisoned", preventing further claims. +/// (We mark it poisoned if the reclamation task crashes, +/// since in that situation we don't want to continue to use memory, unboundedly.) +// +// Poisoned is indicated by We setting to `MAX`. +#[derive(Default, Debug, Deftly, derive_more::Display)] +#[derive_deftly(BookkeptQty)] +#[deftly(allow_nonzero_drop)] // Dropped only when the whole tracker is dropped +pub(super) struct TotalQty { + /// See [`BookkeptQty`] + raw: Qty, +} + +/// Qty used by a participant, found in [`PRecord`].`used`. +/// +/// The tracker data structure has one of these for each Participant. +/// +/// This is the total amount `claim`ed, plus the caches in each `Participation`. +#[derive(Default, Debug, Deftly, derive_more::Display)] +#[derive_deftly(BookkeptQty)] +#[display(fmt = "{raw}")] +pub(super) struct ParticipQty { + /// See [`BookkeptQty`] + raw: Qty, + + /// See [`BookkeptQty`] + bomb: DropBombCondition, +} + +/// "Cached" claim, on behalf of a Participant +/// +/// Found in [`Participation`].`cache`, +/// and accounted to the Participant (ie, included in `ParticipQty`). +/// +/// Also used as a temporary variable in `claim()` and `release()` functions. +/// When we return to the participant, outside the tracker, we +/// essentially throw this away, since we don't give the caller any representation +/// to store. The participant is supposed to track this separately somehow. +#[derive(Default, Debug, Deftly, derive_more::Display)] +#[derive_deftly(BookkeptQty)] +#[display(fmt = "{raw}")] +#[must_use] +pub(super) struct ClaimedQty { + /// See [`BookkeptQty`] + raw: Qty, + + /// See [`BookkeptQty`] + bomb: DropBombCondition, +} + +impl TotalQty { + /// Claim a quantity, increasing the tracked amounts + /// + /// This module doesn't know anything about the memory quota, + /// so this doesn't do the quota check. + /// + /// The only caller is [`Participation::claim`]. + pub(super) fn claim(&mut self, p_used: &mut ParticipQty, want: Qty) -> Option<ClaimedQty> { + // If poisoned, this add will fail (unless want is 0) + let new_self = self.raw.checked_add(*want)?; + if new_self == usize::MAX { + // This would poison us. If this happens, someone has gone mad, since + // we can't have allocated usize::MAX in total. We'll be reclaiming already. + // We don't want to poison ourselves in this situation. Hopefully the reclaim + // will collapse the errant participants. + return None; + } + let new_p_used = p_used.raw.checked_add(*want)?; + // commit + self.raw = Qty(new_self); + p_used.raw = Qty(new_p_used); + Some(ClaimedQty::from_raw(want)) + } + + /// Release a quantity, decreasing the tracked amounts + /// + /// (Handles underflow by saturating; returning an error is not going to be useful.) + pub(super) fn release(&mut self, p_used: &mut ParticipQty, have: ClaimedQty) { + let have = have.into_raw(); + *p_used.raw = p_used.raw.saturating_sub(*have); + + if self.raw != Qty::MAX { + // Don't unpoison + *self.raw = self.raw.saturating_sub(*have); + } + } + + /// Declare this poisoned, and prevent further claims + pub(super) fn set_poisoned(&mut self) { + self.raw = Qty::MAX; + } +} + +impl ClaimedQty { + /// Split a `ClaimedQty` into two `ClaimedQty`s + pub(super) fn split_off(&mut self, want: Qty) -> Option<ClaimedQty> { + let new_self = self.raw.checked_sub(*want)?; + // commit + *self.raw = new_self; + Some(ClaimedQty::from_raw(want)) + } + + /// Merge two `ClaimedQty`s + /// + /// (Handles overflow by saturating; returning an error is not going to be useful.) + pub(super) fn merge_into(&mut self, have: ClaimedQty) { + let have = have.into_raw(); + *self.raw = self.raw.saturating_add(*have); + } + + /// Obtain result for the participant, after having successfully recorded the amount claimed + /// + /// # CORRECTNESS + /// + /// This must be called only on a successful return path from [`Participation::claim`]. + #[allow(clippy::unnecessary_wraps)] // returns Result; proves it's used on success path + pub(super) fn claim_return_to_participant(self) -> crate::Result<()> { + let _: Qty = self.into_raw(); + Ok(()) + } + + /// When the participant indicates a release, enrol the amount in our bookkeping scheme + /// + /// Handles the quantity argument to [`Participation::release`]. + /// + /// # CORRECTNESS + /// + /// This must be called only on entry to [`Participation::release`]. + pub(super) fn release_got_from_participant(got: Qty) -> Self { + ClaimedQty::from_raw(got) + } + + /// Dispose of a quantity that was claimed by a now-destroyed participant + /// + /// # CORRECTNESS + /// + /// The `ParticipQty` this was claimed from must also have been destroyed. + /// + /// So, + /// [`ParticipQty::for_participant_teardown`] and the corresponding + /// [`release`](TotalQty::release) + /// must have been called earlier - possibly, much earlier. + pub(super) fn dispose_participant_destroyed(mut self) { + let _: Qty = mem::take(&mut self).into_raw(); + } +} + +impl ParticipQty { + /// Prepare to destroy the `ParticipQty` in a participant that's being destroyed + /// + /// When the records of a participant that is being torn down are being destroyed, + /// we must remove our records of the memory that it allocated. + /// + /// This function is for that situation. + /// The returned `ClaimedQty` should then be passed to `release`. + /// + /// # CORRECTNESS + /// + /// The data structure where this `ParticipQty` resides + /// must be torn down (after we return). + /// + /// The `ClaimedQty` must be passed to [`TotalQty::release`], + /// passing the same `p_used`. + // + // We could provide this as a single transaction function, rather than requiring + // two calls. But the main code doesn't have a `TotalQty`, only a `TotalQtyNotifier`, + // so we'd need to add an additional passthrough method to `TotalQtyNotifier`, + // which doesn't seem worth it given that there's only one call site for this fn. + pub(super) fn for_participant_teardown(&self) -> ClaimedQty { + // We imagine that the Participant said it was releasing everything + ClaimedQty::from_raw(self.as_raw()) + } +} diff --git a/crates/tor-memquota/src/mtracker/reclaim.rs b/crates/tor-memquota/src/mtracker/reclaim.rs new file mode 100644 index 000000000..6e45b1f3c --- /dev/null +++ b/crates/tor-memquota/src/mtracker/reclaim.rs @@ -0,0 +1,396 @@ +//! Reclamation algorithm +//! +//! Implementation the of long-running [`task`] function, +//! (which is the only export from here, the wider `mtracker` module). + +use super::*; + +mod deferred_drop; + +use deferred_drop::{DeferredDrop, GuardWithDeferredDrop}; + +/// Total number of participants +/// +/// Used in reporting and in calculations of various edge cases. +/// On 64-bit systems, bigger than the refcounts, which are all `u32` +type NumParticips = usize; + +//========== candiate victim analysis ========== + +/// The nominal data age of a participant +#[derive(Ord, PartialOrd, Eq, PartialEq)] +enum Age { + /// Treat this participant as having very old data + TreatAsVeryOld, + /// Data age value from the [`IsParticipant`] + Actual(CoarseInstant), +} + +/// Participant status, as a candidate victim +enum PStatus { + /// Treat participant as having data of age OldestData + Candidate(Age), + /// Tear this participant down right away + TearDown, + /// Treat participant as not having any data; don't reclaim from it + NoData, +} + +/// Outcome of a completed reclamation run +/// +/// This is used only within `choose_victim`, and only for logging +#[derive(Debug, derive_more::Display)] +enum Outcome { + /// We reached the low water mark + #[display(fmt = "complete")] + TargetReached, + + /// We didn't, but we have so many participants that that's possibly expected + /// + /// (Can only happen on 32-bit platforms.) + #[display(fmt = "{} participants, good enough - stopping", n_particips)] + GoodEnough { + /// The number of participants + n_particips: NumParticips, + }, +} + +/// Figure out whether a participant is a candidate victim, and obtain its data age +fn analyse_particip(precord: &PRecord, defer_drop: &mut DeferredDrop) -> PStatus { + let Some(particip) = precord.particip.upgrade() else { + // Oh! This participant has vanished! + // We can't reclaim from it. It may already be reclaiming. + // Delete it from our data structure. + return PStatus::TearDown; + }; + + let got_oldest = catch_unwind(AssertUnwindSafe(|| particip.get_oldest())); + defer_drop.push(particip); + + match got_oldest { + Ok(Some(age)) => return PStatus::Candidate(Age::Actual(age)), + Ok(None) => {} + Err(_panicked) => { + // _panicked is of a useless type + error!("memory tracker: call to get_oldest panicked!"); + return PStatus::TearDown; + } + } + + // The participant claims not to have any memory + // There might be some cached, let's check + + let Some(max_cached) = precord + .refcount + .as_usize() + .checked_mul(MAX_CACHE.as_usize()) + else { + // WTF! So many Participation clones that the max usage has + // overflowed. (This can only happen on 32-bit platforms + // since refcount is a u32.) Probably we should reclaim + // from this participant. + log_ratelim!( + "memtrack: participant with many clones claims to have no data"; + Err::<Void, _>(internal!("{} Participation clones", *precord.refcount)); + ); + return PStatus::Candidate(Age::TreatAsVeryOld); + }; + + if precord.used.as_raw() > Qty(max_cached) { + // This participant is lying to us somehow. + log_ratelim!( + "memtrack: participant claims to have no data, but our accounting disagrees"; + Err::<Void, _>(internal!("{} used (by {} clones)", precord.used, *precord.refcount)); + ); + return PStatus::Candidate(Age::TreatAsVeryOld); + } + + // Participant plausibly does have no data + PStatus::NoData +} + +//========== reclamation algorith, the main pieces ========== + +/// State while reclamation is active +struct Reclaiming { + /// The heap of candidates, oldest at top of heap + heap: BinaryHeap<Reverse<(Age, AId)>>, +} + +/// A victim we have selected for reclamation +/// +/// This designates a specific Participant. +/// +/// But, note that we always reclaim from an Account, so if we are reclaiming +/// from one `Victim`, we may be reclaiming from other `Victim`s with the same +/// `AId` and different `IsParticipant`s. And because of inheritance, we might +/// be reclaiming from other Accounts too. +type Victim = (AId, drop_reentrancy::ProtectedArc<dyn IsParticipant>); + +/// Marker indicating that the victim's reclaim function panicked +struct VictimPanicked; + +/// Set of responses from the victims, after they have all finished reclaiming. +type VictimResponses = Vec<(AId, Result<Reclaimed, VictimPanicked>)>; + +impl Reclaiming { + /// Check to see if we should start reclaiming, and if so return a `Reclaiming` + /// + /// 1. Checks to see if usage is above `max`; if not, returns `None` + /// 2. Logs that we're starting reclamation + /// 3. Calculates the heap of data ages + fn maybe_start(state: &mut GuardWithDeferredDrop) -> Option<Self> { + let (state, deferred_drop) = state.deref_mut_both(); + + if *state.total_used <= state.global.config.max { + return None; + } + + info!( + "memory tracking: {} > {}, reclamation started (target {})", + *state.total_used, state.config.max, state.config.low_water, + ); + + // `BinaryHeap` is a max heap, so use Rev + let mut heap = BinaryHeap::new(); + + // Build heap of participants we might want to reclaim from + // (and, while we're at it, tear down broken participants) + for (aid, arecord) in &mut state.accounts { + arecord.ps.retain(|_pid, precord| { + match analyse_particip(precord, deferred_drop) { + PStatus::Candidate(age) => { + heap.push(Reverse((age, aid))); + true // retain + } + PStatus::NoData => { + true // retain + } + PStatus::TearDown => { + precord.auto_release(&mut state.global); + false // remove + } + } + }); + } + + Some(Reclaiming { heap }) + } + + /// If we're reclaiming, choose the next victim(s) to reclaim + /// + /// This is the account whose participant has the oldest data age, + /// and all of that account's children. + /// + /// We might discover that we didn't want to continue reclamation after all: + /// this function is responsible for checking our progress + /// against the low water mark. + /// + /// If reclamation should stop, this function logs, and returns `None`. + fn choose_victims(&mut self, state: &mut State) -> Result<Option<Vec<Victim>>, ReclaimCrashed> { + let stop = |state: &mut State, outcome| { + info!( + "memory tracking reclamation reached: {} (target {}): {}", + *state.total_used, state.config.low_water, outcome, + ); + Ok(None) + }; + + if *state.total_used <= state.config.low_water { + return stop(state, Outcome::TargetReached); + } + let Some(Reverse((_, oldest_aid))) = self.heap.pop() else { + // All our remaining participants are NoData. + let n_particips: usize = state + .accounts + .values() + .map(|ar| { + ar.ps + .values() + .map( + |pr| *pr.refcount as NumParticips, // refcount is u32, so this is fine + ) + .sum::<NumParticips>() + }) + .sum::<NumParticips>(); + + if state.total_used.as_raw().as_usize() / n_particips < usize::from(MAX_CACHE) { + // On 32-bit, this could happen due to the cache, if we have + // 2^32 / MAX_CACHE participants. + return stop(state, Outcome::GoodEnough { n_particips }); + } + + // Oh dear. + return Err(internal!( + "memory accounting state corrupted: used={} n_particips={} all NoData", + *state.total_used, + n_particips, + ) + .into()); + }; + + // When we do partial reclamation, rather than just Collapsing: + // + // fudge next_oldest by something to do with number of loop iterations, + // to avoid one-allocation-each-time ping pong between multiple caches + // + // (this match statement will fail to compile when we add a non-Collapsing variant) + // + // let next_oldest = heap.peek_lowest(); + match None { + None | Some(Reclaimed::Collapsing) => {} + } + + let victim_aids = state.get_aid_and_children_recursively(oldest_aid); + + let victims: Vec<Victim> = { + let mut particips = vec![]; + for aid in victim_aids { + let Some(arecord) = state.accounts.get_mut(aid) else { + // shouldn't happen but no need to panic + continue; + }; + arecord.ps.retain(|_pid, precord| { + let Some(particip) = precord.particip.upgrade() else { + // tear this down! + precord.auto_release(&mut state.global); + return false; + }; + particips.push((aid, particip)); + true + }); + } + particips + }; + + Ok(Some(victims)) + } + + /// Notify the chosen victims and obtain their responses + /// + /// This is the async part, and is done with the state unlocked. + // Doesn't actually need `self`, only `victims`, but we take it for form's sake + async fn notify_victims(&mut self, victims: Vec<Victim>) -> VictimResponses { + futures::future::join_all( + // + victims.into_iter().map(|(aid, particip)| async move { + let particip = particip.promise_dropping_is_ok(); + // We run the `.reclaim()` calls within the same task (since that's what + // `join_all` does). So they all run on whatever executor thread is polling + // the reclamation task. + let reclaimed = AssertUnwindSafe(particip.reclaim()) + .catch_unwind() + .await + .map_err(|_panicked| VictimPanicked); + // We drop the `ProtectedArc<dyn IsParticipant>` here, which is OK + // because we don't hold the lock. Since drop isn't async, and + // `join_all` doesn't spawn tasks, we drop them sequentially. + (aid, reclaimed) + }), + ) + .await + } + + /// Process the victim's responses and update `state` accordingly + // Doesn't actually need `self`, only `state`, but we take it for form's sake + fn handle_victim_responses(&mut self, state: &mut State, responses: VictimResponses) { + for (aid, reclaimed) in responses { + match reclaimed { + Ok(Reclaimed::Collapsing) | Err(VictimPanicked) => { + let Some(mut arecord) = state.accounts.remove(aid) else { + // Account is gone, fair enough + continue; + }; + arecord.auto_release(&mut state.global); + // Account is definitely gone now + } + } + } + } +} + +//========== the reclamation task, in terms of the pieces ==========- + +/// Return value from the task, when it finishes due to the tracker being shut down +struct TaskFinished; + +/// Reclaim memory until we reach low water, if necessary +/// +/// Looks to see if we're above `config.max`. +/// If so, constructs a list of victims, and starts reclaiming from them, +/// until we reach low water. +async fn inner_loop(tracker: &Arc<MemoryQuotaTracker>) -> Result<(), ReclaimCrashed> { + let mut reclaiming; + let mut victims; + { + let mut state_guard = GuardWithDeferredDrop::new(tracker.lock()?); + + let Some(r) = Reclaiming::maybe_start(&mut state_guard) else { + return Ok(()); + }; + reclaiming = r; + + // Duplicating this call to reclaiming.choose_victims means we don't + // release the lock between `maybe_start` and `choose_victims` (here) + // and between `handle_victim_responses` and `choose_victims` (bellw). + // (Releasing the lock would not be a bug, but it's not desirable.) + let Some(v) = reclaiming.choose_victims(&mut state_guard)? else { + return Ok(()); + }; + victims = v; + } + + loop { + let responses = reclaiming.notify_victims(mem::take(&mut victims)).await; + let mut state_guard = tracker.lock()?; + reclaiming.handle_victim_responses(&mut state_guard, responses); + let Some(v) = reclaiming.choose_victims(&mut state_guard)? else { + return Ok(()); + }; + victims = v; + } +} + +/// Internal long-running task, handling reclamation - main loop +/// +/// Handles routine logging, but not termination +async fn task_loop( + tracker: &Weak<MemoryQuotaTracker>, + mut wakeup: mpsc::Receiver<()>, +) -> Result<TaskFinished, ReclaimCrashed> { + loop { + // We don't hold a strong reference while we loop around, so we detect + // last drop of an actual client handle. + { + let Some(tracker) = tracker.upgrade() else { + return Ok(TaskFinished); + }; + + inner_loop(&tracker).await?; + } + + let Some(()) = wakeup.next().await else { + // Sender dropped + return Ok(TaskFinished); + }; + } +} + +/// Internal long-running task, handling reclamation +/// +/// This is the entrypoint used by the rest of the `tracker`. +/// It handles logging of crashes. +pub(super) async fn task(tracker: Weak<MemoryQuotaTracker>, wakeup: mpsc::Receiver<()>) { + match task_loop(&tracker, wakeup).await { + Ok(TaskFinished) => {} + Err(bug) => { + let _: Option<()> = (|| { + let tracker = tracker.upgrade()?; + let mut state = tracker.state.lock().ok()?; + state.total_used.set_poisoned(); + Some(()) + })(); + error_report!(bug, "memory tracker task failed"); + } + } +} diff --git a/crates/tor-memquota/src/mtracker/reclaim/deferred_drop.rs b/crates/tor-memquota/src/mtracker/reclaim/deferred_drop.rs new file mode 100644 index 000000000..3b0742a82 --- /dev/null +++ b/crates/tor-memquota/src/mtracker/reclaim/deferred_drop.rs @@ -0,0 +1,81 @@ +//! Deferred drop handling. +//! +//! We sometimes have `Arc<dyn Participant>`s we have obtained but don't want to drop yet +//! +//! See the top-level docs for context. +//! +//! When we drop the `Arc`, the refcount might become zero. +//! Then the inner type would be dropped. +//! The inner type is allowed to call back into us (for example, it may drop an `Account`). +//! We must therefore not drop a caller's `Participant` with our own state lock held. +//! +//! This module has a helper type for assuring that we do defer drops. +// +// There are no separate tests for this module. Drop bombs are hard to test for, +// and the rest of the code is just wrappers. + +use super::*; + +/// `MutexGuard<State>` but also a list of `Arc<dyn Partcipant>` to drop when we unlock +#[derive(Debug, Default)] +pub(super) struct GuardWithDeferredDrop<'m> { + /// The mutex guard + /// + /// Always `Some`; just an `Option` so we can move out during drop + guard: Option<MutexGuard<'m, State>>, + + /// The participants we've acquired and which we want to drop later + deferred_drop: DeferredDrop, +} + +/// Participants we've acquired and which we want to drop later, convenience alias +pub(super) type DeferredDrop = Vec<drop_reentrancy::ProtectedArc<dyn IsParticipant>>; + +impl<'m> GuardWithDeferredDrop<'m> { + /// Prepare for handling deferred drops + pub(super) fn new(guard: MutexGuard<'m, State>) -> Self { + GuardWithDeferredDrop { + guard: Some(guard), + deferred_drop: vec![], + } + } + + /// Obtain mutable borrows of the two components + pub(super) fn deref_mut_both(&mut self) -> (&mut State, &mut DeferredDrop) { + ( + self.guard.as_mut().expect("deref_mut after drop"), + &mut self.deferred_drop, + ) + } +} + +impl Deref for GuardWithDeferredDrop<'_> { + type Target = State; + fn deref(&self) -> &State { + self.guard.as_ref().expect("deref after drop") + } +} +impl DerefMut for GuardWithDeferredDrop<'_> { + fn deref_mut(&mut self) -> &mut State { + self.deref_mut_both().0 + } +} + +// We use ProtectedArc. In tests, that has a drop bomb which requires us to +// call `.promise_dropping_is_ok()`, on pain of panicking. So we must do that here. +// +// Outside tests, the normal drop order would be precisely correct: +// the guard field comes first, so the compiler would drop it before the Arcs. +// So we could make this `#[cfg(test)]` (and add some comments above about field order). +// However, we prefer to use the same code, so that the correctness of +// *production* GuardWithDeferredDrop is assured by the `ProtectedArc`. +impl Drop for GuardWithDeferredDrop<'_> { + fn drop(&mut self) { + let guard = self.guard.take().expect("dropping twice!"); + drop::<MutexGuard<_>>(guard); + // we just unlocked the guard, so drops that re-enter our code are fine + for p in self.deferred_drop.drain(..) { + p.promise_dropping_is_ok(); + } + } +} diff --git a/crates/tor-memquota/src/mtracker/test.rs b/crates/tor-memquota/src/mtracker/test.rs new file mode 100644 index 000000000..43d3dcd7c --- /dev/null +++ b/crates/tor-memquota/src/mtracker/test.rs @@ -0,0 +1,678 @@ +//! `tor_memtrack::tracker::test` + +// @@ begin test lint list maintained by maint/add_warning @@ +#![allow(clippy::bool_assert_comparison)] +#![allow(clippy::clone_on_copy)] +#![allow(clippy::dbg_macro)] +#![allow(clippy::mixed_attributes_style)] +#![allow(clippy::print_stderr)] +#![allow(clippy::print_stdout)] +#![allow(clippy::single_char_pattern)] +#![allow(clippy::unwrap_used)] +#![allow(clippy::unchecked_duration_subtraction)] +#![allow(clippy::useless_vec)] +#![allow(clippy::needless_pass_by_value)] +//! <!-- @@ end test lint list maintained by maint/add_warning @@ --> +#![allow(clippy::let_and_return)] // TODO this lint is annoying and we should disable it + +use super::*; + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::time::Duration; + +use itertools::Itertools; +use rand::Rng; +use slotmap::Key as _; +use tracing_test::traced_test; + +use tor_basic_utils::RngExt as _; +use tor_rtcompat::{CoarseDuration, CoarseTimeProvider as _, Runtime}; +use tor_rtmock::MockRuntime; + +//---------- useful utilities ---------- + +fn secs(s: u64) -> CoarseDuration { + Duration::from_secs(s).into() +} + +fn mby(mib: usize) -> usize { + mib * 1024 * 1024 +} + +fn mk_config() -> Config { + Config::builder() + .max(mby(20)) + .low_water(mby(15)) + .build() + .unwrap() +} + +fn mk_tracker(rt: &impl Runtime) -> Arc<MemoryQuotaTracker> { + MemoryQuotaTracker::new(&rt, mk_config()).unwrap() +} + +fn test_with_various_mocks<F, Fut>(f: F) +where + F: Fn(tor_rtmock::MockRuntime) -> Fut, + Fut: Future<Output = ()>, +{ + MockRuntime::test_with_various(|rt| async { + // Make sure we can talk about times at least 1000s in the past + // TODO maybe this should be a feature of MockRuntime but what value to pick? + rt.advance_by(Duration::from_secs(1000)).await; + f(rt).await; + }); +} + +//---------- consistency check (test invariants against outside view) ---------- + +use consistency::*; +mod consistency { + use super::*; + + #[derive(Default)] + pub(super) struct CallerInfoCollector { + g: usize, + acs: BTreeMap<AId, refcount::RawCount>, + pcs: BTreeMap<(AId, PId), (refcount::RawCount, usize)>, + debug_dump: String, + } + + pub(super) trait HasCallerInfo { + fn note_consistency_caller_info(&self, collector: &mut CallerInfoCollector); + } + + impl CallerInfoCollector { + pub(super) fn note_account(&mut self, acct: &Account, reclaimed: ReclaimedOrOk) { + writeln!(self.debug_dump, "acct {acct:?} {reclaimed:?}").unwrap(); + if acct.aid.is_null() || reclaimed.is_err() { + return; + } + let ac = self.acs.entry(*acct.aid).or_default(); + *ac += 1; + } + pub(super) fn note_particip( + &mut self, + p: &Participation, + reclaimed: ReclaimedOrOk, + used: usize, + ) { + writeln!(self.debug_dump, "particip {p:?} {reclaimed:?} {used:?}").unwrap(); + if p.pid.is_null() || p.aid.is_null() || reclaimed.is_err() { + return; + } + self.note_partn_core(p, used); + } + pub(super) fn note_partn_clone(&mut self, p: &Participation) { + writeln!(self.debug_dump, "partn {p:?}").unwrap(); + if p.pid.is_null() { + return; + } + self.note_partn_core(p, 0); + } + fn note_partn_core(&mut self, p: &Participation, x_used: usize) { + let pc = self.pcs.entry((p.aid, *p.pid)).or_default(); + let used = *p.cache.as_raw() + x_used; + pc.0 += 1; + pc.1 += used; + self.g += used; + } + } + + pub(super) fn check_consistency_general( + trk: &Arc<MemoryQuotaTracker>, + collect_caller_info: impl FnOnce(&mut CallerInfoCollector), + ) { + let state = trk.lock().unwrap(); + + let (expected, debug_dump) = { + let mut c = CallerInfoCollector::default(); + collect_caller_info(&mut c); + ((c.g, c.acs, c.pcs), c.debug_dump) + }; + + let got = { + let mut gc = 0; + let mut acs = BTreeMap::new(); + let mut pcs = BTreeMap::new(); + for (aid, arecord) in &state.accounts { + acs.insert(aid, *arecord.refcount); + for (pid, precord) in &arecord.ps { + let used = *precord.used.as_raw(); + gc += used; + pcs.insert((aid, pid), (*precord.refcount, used)); + } + } + + (gc, acs, pcs) + }; + + assert_eq!( + expected, got, + "\n----- dump (start) -----\n{debug_dump}----- dump (end) -----", + ); + } +} + +//---------- common test participant (state) ---------- + +#[derive(Debug)] +struct PartnState { + partn: Participation, + age: Option<CoarseInstant>, + used: usize, + reclaimed: ReclaimedOrOk, + show: String, +} + +#[derive(Debug)] +struct TestPartn { + state: Mutex<PartnState>, +} + +impl TestPartn { + fn lock(&self) -> MutexGuard<PartnState> { + self.state.lock().unwrap() + } +} + +impl From<PartnState> for TestPartn { + fn from(state: PartnState) -> TestPartn { + TestPartn { + state: Mutex::new(state), + } + } +} + +impl TestPartn { + fn get_oldest(&self) -> Option<CoarseInstant> { + self.lock().age + } + fn reclaim(&self) -> ReclaimFuture { + let () = mem::replace(&mut self.lock().reclaimed, Err(())).expect("reclaimed twice!"); + Box::pin(async { Reclaimed::Collapsing }) + } + fn is_reclaimed(&self) -> Result<(), ()> { + self.lock().reclaimed + } +} + +impl IsParticipant for TestPartn { + fn get_oldest(&self) -> Option<CoarseInstant> { + self.get_oldest() + } + fn reclaim(self: Arc<Self>) -> ReclaimFuture { + (*self.clone()).reclaim() + } +} + +impl PartnState { + fn claim(&mut self, qty: usize) -> Result<(), crate::Error> { + claim_via(&mut self.partn, &self.show, &mut self.used, qty) + } + + fn release(&mut self, qty: usize) { + release_via(&mut self.partn, &self.show, &mut self.used, qty); + } +} + +fn claim_via( + via: &mut Participation, + show: impl Display, + used: &mut usize, + qty: usize, +) -> Result<(), crate::Error> { + eprintln!("{show} claim {qty} {qty:#x}"); + via.claim(qty)?; + *used += qty; + Ok(()) +} + +fn release_via(via: &mut Participation, show: impl Display, used: &mut usize, qty: usize) { + eprintln!("{show} release {qty} {qty:#x}"); + via.release(qty); + *used -= qty; +} + +impl HasCallerInfo for PartnState { + fn note_consistency_caller_info(&self, collector: &mut CallerInfoCollector) { + collector.note_particip(&self.partn, self.reclaimed, self.used); + } +} + +//---------- test participant which is directly the accountholder ---------- + +#[derive(Debug, Deref)] +struct UnifiedP { + acct: Account, + #[deref] + state: TestPartn, +} + +type ReclaimedOrOk = Result<(), ()>; + +impl IsParticipant for UnifiedP { + fn get_oldest(&self) -> Option<CoarseInstant> { + self.state.get_oldest() + } + fn reclaim(self: Arc<Self>) -> ReclaimFuture { + self.state.reclaim() + } +} + +impl UnifiedP { + fn new( + rt: &impl Runtime, + trk: &Arc<MemoryQuotaTracker>, + parent: Option<&Account>, + age: CoarseDuration, + show: impl Display, + ) -> Arc<Self> { + let acct = trk.new_account(parent).unwrap(); + + let now = rt.now_coarse(); + + acct.register_participant_with(now, |partn| { + Ok::<_, Void>(Arc::new(UnifiedP { + acct: acct.clone(), + state: PartnState { + partn, + age: Some(now - age), + show: show.to_string(), + used: 0, + reclaimed: Ok(()), + } + .into(), + })) + }) + .unwrap() + .void_unwrap() + } + + async fn settle_check_consistency<'i>( + rt: &'i MockRuntime, + trk: &'i Arc<MemoryQuotaTracker>, + ups: impl IntoIterator<Item = &'i Arc<Self>> + 'i, + ) { + rt.advance_until_stalled().await; + + check_consistency_general(trk, |collector| { + for up in ups { + up.note_consistency_caller_info(collector); + } + }); + } +} + +impl HasCallerInfo for UnifiedP { + fn note_consistency_caller_info(&self, collector: &mut CallerInfoCollector) { + let state = self.lock(); + collector.note_account(&self.acct, state.reclaimed); + state.note_consistency_caller_info(collector); + } +} + +//---------- test cases with unified accountholder/participant ---------- + +#[traced_test] +#[test] +fn basic() { + test_with_various_mocks(|rt| async move { + let trk = mk_tracker(&rt); + + let ps: Vec<Arc<UnifiedP>> = (0..21) + .map(|i| UnifiedP::new(&rt, &trk, None, secs(i), i)) + .collect(); + + for p in &ps[0..19] { + p.lock().claim(mby(1)).unwrap(); + UnifiedP::settle_check_consistency(&rt, &trk, &ps).await; + } + + let count_uncollapsed = || ps.iter().filter(|p| p.is_reclaimed().is_ok()).count(); + + assert_eq!(count_uncollapsed(), 21); + + for p in &ps[20..] { + // check that we are exercising a situation with nonzero cached + // (this is set up by register_participant + assert_ne!(p.lock().partn.cache, Qty(0)); + + p.lock() + .claim(mby(1)) + .expect("allocation rejected, during collapse, but collapse is async"); + } + + UnifiedP::settle_check_consistency(&rt, &trk, &ps).await; + + assert_eq!(count_uncollapsed(), 14); + + // Now we drop everything. This exercises much of the teardown! + }); +} + +#[traced_test] +#[test] +fn parent() { + test_with_various_mocks(|rt| async move { + for ages in [[10, 20], [20, 10]] { + eprintln!("ages: {ages:?}"); + let [parent_age, child_age] = ages.map(secs); + + let trk = mk_tracker(&rt); + + let mk_p = |parent, age, show| UnifiedP::new(&rt, &trk, parent, age, show); + + let parent = mk_p(None, parent_age, "parent"); + parent.lock().claim(mby(7)).unwrap(); + rt.advance_until_stalled().await; + assert!(parent.is_reclaimed().is_ok()); + + let child = mk_p(Some(&parent.acct), child_age, "child"); + child.lock().claim(mby(7)).unwrap(); + assert!(parent.is_reclaimed().is_ok()); + assert!(child.is_reclaimed().is_ok()); + + let trigger = mk_p(None, secs(0), "trigger"); + trigger.lock().claim(mby(7)).unwrap(); + assert!(trigger.is_reclaimed().is_ok()); + + rt.advance_until_stalled().await; + + if parent_age > child_age { + // parent is older than child, we're supposed to have reclaimed + // from the parent, causing reclamation of the child. + assert!(parent.is_reclaimed().is_err()); + assert!(child.is_reclaimed().is_err()); + } else { + // supposed to have reclaimed from child only + assert!(parent.is_reclaimed().is_ok()); + assert!(child.is_reclaimed().is_err()); + } + } + }); +} + +#[traced_test] +#[test] +fn cache() { + test_with_various_mocks(|rt| async move { + let seq = [ + 1, + 1000, + *MAX_CACHE - 2000, + 3000, + *MAX_CACHE, + *MAX_CACHE - 1, + *MAX_CACHE + 1, + ]; + + let trk = mk_tracker(&rt); + let p = UnifiedP::new(&rt, &trk, None, secs(0), "p"); + + for qty in seq { + p.lock().claim(qty).unwrap(); + UnifiedP::settle_check_consistency(&rt, &trk, [&p]).await; + } + + for qty in seq { + p.lock().release(qty); + UnifiedP::settle_check_consistency(&rt, &trk, [&p]).await; + } + + let mut p2 = p.lock().partn.clone(); + + let mut rng = tor_basic_utils::test_rng::Config::Deterministic.into_rng(); + for _iter in 0..10_000 { + let qty = rng.gen_range_checked(0..=*MAX_CACHE).unwrap(); + let p_use_i = rng.gen_range_checked(1..=3).unwrap(); + { + let mut state = p.lock(); + let state = &mut *state; + + let mut p_use_buf; + let p_use = match p_use_i { + 1 => &mut state.partn, + 2 => &mut p2, + 3 => { + p_use_buf = p2.clone(); + &mut p_use_buf + } + x => panic!("{}", x), + }; + + if rng.gen() || qty > state.used { + claim_via(p_use, p_use_i, &mut state.used, qty).unwrap(); + } else { + release_via(p_use, p_use_i, &mut state.used, qty); + } + } + + rt.advance_until_stalled().await; + check_consistency_general(&trk, |collector| { + p.note_consistency_caller_info(collector); + collector.note_partn_clone(&p2); + }); + } + }); +} + +//---------- test client with multiple participants per account ---------- + +#[derive(Debug)] +struct ComplexAH { + acct: Account, + ps: Vec<Arc<TestPartn>>, +} + +impl HasCallerInfo for ComplexAH { + fn note_consistency_caller_info(&self, collector: &mut CallerInfoCollector) { + let reclaimed = self + .ps + .iter() + .map(|p| p.lock().reclaimed) + .dedup() + .exactly_one() + .unwrap(); + + collector.note_account(&self.acct, reclaimed); + for p in &self.ps { + p.lock().note_consistency_caller_info(collector); + } + } +} + +impl ComplexAH { + fn new(trk: &Arc<MemoryQuotaTracker>) -> Self { + ComplexAH { + acct: trk.new_account(None).unwrap(), + ps: vec![], + } + } + + fn add_p(&mut self, now: CoarseInstant, age: CoarseDuration, show: impl Display) -> usize { + let cp = self + .acct + .register_participant_with(now, |partn| { + Ok::<_, Void>(Arc::new(TestPartn::from(PartnState { + partn, + age: Some(now - age), + show: show.to_string(), + used: 0, + reclaimed: Ok(()), + }))) + }) + .unwrap() + .void_unwrap(); + + let i = self.ps.len(); + self.ps.push(cp); + i + } +} + +#[traced_test] +#[test] +fn complex() { + test_with_various_mocks(|rt| async move { + let trk = mk_tracker(&rt); + + let up = UnifiedP::new(&rt, &trk, None, secs(0), "U"); + let mut ah = ComplexAH::new(&trk); + let now = rt.now_coarse(); + + for age in [5, 9] { + ah.add_p(now, secs(age), age); + } + + let settle_check_consistency = || async { + rt.advance_until_stalled().await; + + check_consistency_general(&trk, |collector| { + up.note_consistency_caller_info(collector); + ah.note_consistency_caller_info(collector); + }); + }; + + up.lock().claim(mby(1)).unwrap(); + ah.ps[0].lock().claim(mby(11)).unwrap(); + + settle_check_consistency().await; + + assert!(up.is_reclaimed().is_ok()); + for p in &ah.ps { + assert!(p.is_reclaimed().is_ok()); + } + + ah.ps[1].lock().claim(mby(11)).unwrap(); + + settle_check_consistency().await; + assert!(up.is_reclaimed().is_ok()); + for p in &ah.ps { + assert!(p.is_reclaimed().is_err()); + } + }); +} + +//---------- various error cases ---------- + +#[derive(Debug)] +struct DummyParticipant; + +impl IsParticipant for DummyParticipant { + fn get_oldest(&self) -> Option<CoarseInstant> { + None + } + fn reclaim(self: Arc<Self>) -> ReclaimFuture { + Box::pin(async { Reclaimed::Collapsing }) + } +} + +#[traced_test] +#[test] +fn errors() { + test_with_various_mocks(|rt| async move { + let trk = mk_tracker(&rt); + let now = rt.now_coarse(); + + let mk_ah = || { + let mut ah = ComplexAH::new(&trk); + ah.add_p(now, secs(5), "p"); + ah + }; + + const CLAIM: usize = MAX_CACHE.as_usize() + 1; + + let dummy_dangling = || { + let p = Arc::new(DummyParticipant); + Arc::downgrade(&p) + // p dropped here + }; + assert!(dummy_dangling().upgrade().is_none()); + + macro_rules! assert_error { { $error:ident, $r:expr } => { + let r = $r; + assert!(matches!(r, Err(Error::$error)), "unexpected: {:?} => {:?}", stringify!($r), &r); + } } + + // Dropped account + { + let mut ah = mk_ah(); + let wa1: WeakAccount = ah.acct.downgrade(); + let p = ah.ps.pop().unwrap(); + let wa2: WeakAccount = p.lock().partn.account(); + drop(ah.acct); + + rt.advance_until_stalled().await; + check_consistency_general(&trk, |_collector| ()); + + // account should be dead now + assert!(p.lock().claim(1).is_ok()); // from cache! + assert_error!(AccountClosed, p.lock().claim(CLAIM)); + assert_error!(AccountClosed, wa1.upgrade()); + assert_error!(AccountClosed, wa2.upgrade()); + + // but we can still release + p.lock().release(1); + } + + // Dropped IsParticipant + { + let mut ah = mk_ah(); + let p = ah.ps.pop().unwrap(); + let mut state = Arc::into_inner(p).unwrap().state.into_inner().unwrap(); + + state.claim(mby(30)).unwrap(); // will trigger reclaim, which discovers the loss + + rt.advance_until_stalled().await; + check_consistency_general(&trk, |collector| { + let reclaimed = Ok(()); // didn't manage to make the callback! + collector.note_account(&ah.acct, reclaimed); + }); + + assert_error!(ParticipantShutdown, state.claim(CLAIM)); + } + + // Reclaimed account + { + let ah = mk_ah(); + ah.ps[0].lock().claim(mby(30)).unwrap(); + + rt.advance_until_stalled().await; + check_consistency_general(&trk, |_collector| ()); + + let p = &ah.ps[0]; + + assert!(p.lock().reclaimed.is_err()); + assert_error!(AccountClosed, p.lock().claim(CLAIM)); + + let cloned = ah.acct.clone(); + assert!(cloned.aid.is_null()); + assert_error!( + AccountClosed, + ah.acct.register_participant(dummy_dangling()) + ); + + let mut cloned = p.lock().partn.clone(); + assert!(cloned.pid.is_null()); + assert_error!(AccountClosed, cloned.claim(CLAIM)); + + // but we can still release + p.lock().release(1); + } + + // Dropped tracker + { + let mut ah = mk_ah(); + let p = ah.ps.pop().unwrap(); + let wa = ah.acct.downgrade(); + drop(ah.acct); + let _: MemoryQuotaTracker = Arc::into_inner(trk).unwrap(); + + assert_error!(TrackerShutdown, wa.upgrade()); + assert_error!(TrackerShutdown, p.lock().partn.account().upgrade()); + assert_error!(TrackerShutdown, p.lock().claim(CLAIM)); + } + }); +} diff --git a/crates/tor-memquota/src/mtracker/total_qty_notifier.rs b/crates/tor-memquota/src/mtracker/total_qty_notifier.rs new file mode 100644 index 000000000..4c877ba9a --- /dev/null +++ b/crates/tor-memquota/src/mtracker/total_qty_notifier.rs @@ -0,0 +1,70 @@ +//! `TotalQtyNotifier` +//! +//! This newtype assures that we wake up the reclamation task when nceessary + +use super::*; + +/// Wrapper for `TotalQty` +#[derive(Deref, Debug)] +pub(super) struct TotalQtyNotifier { + /// Total memory usage + /// + /// Invariant: equal to + /// ```text + /// Σ Σ PRecord.used + /// ARecord PRecord + /// ``` + #[deref] + total_used: TotalQty, + + /// Condvar to wake up the reclamation task + /// + /// The reclamation task has another clone of this + reclamation_task_wakeup: mpsc::Sender<()>, +} + +impl TotalQtyNotifier { + /// Make a new `TotalQtyNotifier`, which will notify a specified condvar + pub(super) fn new_zero(reclamation_task_wakeup: mpsc::Sender<()>) -> Self { + TotalQtyNotifier { + total_used: TotalQty::ZERO, + reclamation_task_wakeup, + } + } + + /// Record that some memory has been (or will be) allocated by a participant + /// + /// Signals the wakeup task if we need to. + pub(super) fn claim( + &mut self, + precord: &mut PRecord, + want: Qty, + config: &Config, + ) -> crate::Result<ClaimedQty> { + let got = self + .total_used + .claim(&mut precord.used, want) + .ok_or_else(|| internal!("integer overflow attempting to add claim {}", want))?; + if self.total_used > config.max { + match self.reclamation_task_wakeup.try_send(()) { + Ok(()) => Ok(()), + Err(e) if e.is_full() => Ok(()), + Err(e) => Err(into_internal!("could not notify reclamation task!")(e)), + }?; + } + Ok(got) + } + + /// Declare this poisoned, and prevent further claims + pub(super) fn set_poisoned(&mut self) { + self.total_used.set_poisoned(); + } + + /// Record that some memory has been (or will be) freed by a participant + pub(super) fn release(&mut self, precord: &mut PRecord, have: ClaimedQty) // infallible + { + // TODO if the participant's usage underflows, tell it to reclaim + // (and log some kind of internal error) + self.total_used.release(&mut precord.used, have); + } +} |
