Skip to main content

tor_memquota/
config.rs

1//! Configuration (private module)
2
3use std::sync::LazyLock;
4
5use sysinfo::{MemoryRefreshKind, System};
6use tracing::warn;
7
8use crate::internal_prelude::*;
9
10/// We want to support at least this many participants with a cache each
11///
12/// This is not a recommended value; it's probably too lax
13const MIN_MAX_PARTICIPANTS: usize = 10;
14
15/// Minimum hysteresis
16///
17/// This is not a recommended value; it's probably far too lax for sensible performance!
18const MAX_LOW_WATER_RATIO: f32 = 0.98;
19
20define_derive_deftly! {
21    /// Define setters on the builder for every field of type `Qty`
22    ///
23    /// The field type must be spelled precisely that way:
24    /// we use `approx_equal(...)`.
25    QtySetters:
26
27    impl ConfigBuilder {
28      $(
29        ${when approx_equal($ftype, { Option::<ExplicitOrAuto<Qty>> })}
30
31        ${fattrs doc}
32        ///
33        /// (Setter method.)
34        // We use `value: impl Into<ExplicitOrAuto<usize>>` to avoid breaking users who used the
35        // previous `value: usize`. But this isn't 100% foolproof, for example if a user used
36        // `$fname(foo.into())`, which will fail type inference.
37        pub fn $fname(&mut self, value: impl Into<ExplicitOrAuto<usize>>) -> &mut Self {
38            self.$fname = Some(value.into().map(Qty));
39            self
40        }
41      )
42    }
43}
44
45/// Configuration for a memory data tracker
46///
47/// This is where the quota is specified.
48///
49/// This type can also represent
50/// "memory quota tracking is not supposed to be enabled".
51#[derive(Debug, Clone, Eq, PartialEq)]
52pub struct Config(pub(crate) IfEnabled<ConfigInner>);
53
54/// Configuration for a memory data tracker (builder)
55//
56// We could perhaps generate this with `#[derive(Builder)]` on `ConfigInner`,
57// but derive-builder would need a *lot* of overriding attributes;
58// and, doing it this way lets us write separate docs about
59// the invariants on our fields, which are not the same as those in the builder.
60#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Default, Deftly)]
61#[derive_deftly(tor_config::Flattenable, QtySetters)]
62pub struct ConfigBuilder {
63    /// Maximum memory usage tolerated before reclamation starts
64    ///
65    /// Setting this to `usize::MAX` disables the memory quota.
66    ///
67    /// The default is "auto",
68    /// which uses a value derived from the total system memory.
69    /// It should not be assumed that the value used for "auto"
70    /// will remain stable across different versions of this library.
71    ///
72    /// Note that this is not a hard limit.
73    /// See Approximate in [the overview](crate).
74    max: Option<ExplicitOrAuto<Qty>>,
75
76    /// Reclamation will stop when memory use is reduced to below this value
77    ///
78    /// Default is "auto", which uses 75% of the maximum.
79    /// It should not be assumed that the value used for "auto"
80    /// will remain stable across different versions of this library.
81    ///
82    /// If set to an explicit value,
83    /// then `max` must be set to an explicit value as well.
84    low_water: Option<ExplicitOrAuto<Qty>>,
85}
86
87// NOTE: We derive this manually since the derive_deftly ExtendBuilder macro applies to the
88// _config_ type. :/
89impl tor_config::extend_builder::ExtendBuilder for ConfigBuilder {
90    fn extend_from(&mut self, other: Self, _: tor_config::extend_builder::ExtendStrategy) {
91        if let Some(max) = other.max {
92            self.max = Some(max);
93        }
94        if let Some(low_water) = other.low_water {
95            self.low_water = Some(low_water);
96        }
97    }
98}
99
100/// Configuration, if enabled
101#[derive(Debug, Clone, Eq, PartialEq, Deftly)]
102#[cfg_attr(
103    feature = "testing",
104    visibility::make(pub),
105    allow(clippy::exhaustive_structs)
106)]
107pub(crate) struct ConfigInner {
108    /// Maximum memory usage
109    ///
110    /// Guaranteed not to be `MAX`, since we're enabled
111    pub max: Qty,
112
113    /// Low water
114    ///
115    /// Guaranteed to be enough lower than `max`
116    pub low_water: Qty,
117}
118
119impl Config {
120    /// Start building a [`Config`]
121    ///
122    /// Returns a fresh default [`ConfigBuilder`].
123    pub fn builder() -> ConfigBuilder {
124        ConfigBuilder::default()
125    }
126
127    /// Obtain the actual configuration, if we're enabled, or `None` if not
128    ///
129    /// Ad-hoc accessor for testing purposes.
130    /// (ideally we'd use `visibility` to make fields `pub`, but that doesn't work.)
131    #[cfg(any(test, feature = "testing"))]
132    #[cfg_attr(feature = "testing", visibility::make(pub))]
133    fn inner(&self) -> Option<&ConfigInner> {
134        self.0.as_ref().into_enabled()
135    }
136}
137
138impl ConfigBuilder {
139    /// Builds a new `Config` from a builder
140    ///
141    /// Returns an error if the fields values are invalid or inconsistent.
142    pub fn build(&self) -> Result<Config, ConfigBuildError> {
143        // both options default to "auto"
144        let max = self.max.unwrap_or(ExplicitOrAuto::Auto);
145        let low_water = self.low_water.unwrap_or(ExplicitOrAuto::Auto);
146
147        // `MAX` indicates "disabled".
148        // TODO: Should we add a new "enabled" config option instead of using a sentinel value?
149        // But this would be a breaking change. Or maybe we should always enable the memquota
150        // machinery even if the user chooses an unreasonably large value, and not give users a way
151        // to disable it.
152        if max == ExplicitOrAuto::Explicit(Qty::MAX) {
153            // If it should be disabled, but the user provided an explicit value for `low_water`.
154            if matches!(low_water, ExplicitOrAuto::Explicit(_)) {
155                return Err(ConfigBuildError::Inconsistent {
156                    fields: vec!["max".into(), "low_water".into()],
157                    problem: "low_water supplied, but max indicates that we should disable the memory quota".into(),
158                });
159            };
160            return Ok(Config(IfEnabled::Noop));
161        }
162
163        // We don't want the user to set "auto" for `max`, but an explicit value for `low_water`.
164        // Otherwise this config is prone to breaking since a `max` of "auto" may change as system
165        // memory is removed (either physically or if running in a VM/container).
166        if matches!(max, ExplicitOrAuto::Auto) && matches!(low_water, ExplicitOrAuto::Explicit(_)) {
167            return Err(ConfigBuildError::Inconsistent {
168                fields: vec!["max".into(), "low_water".into()],
169                problem: "max is \"auto\", but low_water is set to an explicit quantity".into(),
170            });
171        }
172
173        let enabled = EnabledToken::new_if_compiled_in()
174            //
175            .ok_or_else(|| ConfigBuildError::NoCompileTimeSupport {
176                field: "max".into(),
177                problem: "cargo feature `memquota` disabled (in tor-memquota crate)".into(),
178            })?;
179
180        // The general logic is taken from c-tor (see `compute_real_max_mem_in_queues`).
181        // NOTE: Relays have an additional lower bound for explicitly given values (64 MiB),
182        // but we have no way of knowing whether we are a relay or not here.
183        let max = match max {
184            ExplicitOrAuto::Explicit(x) => x,
185            ExplicitOrAuto::Auto => compute_max_from_total_system_mem(total_available_memory()),
186        };
187
188        let low_water = match low_water {
189            ExplicitOrAuto::Explicit(x) => x,
190            ExplicitOrAuto::Auto => Qty((*max as f32 * 0.75) as _),
191        };
192
193        let config = ConfigInner { max, low_water };
194
195        /// Minimum low water.  `const` so that overflows are compile-time.
196        const MIN_LOW_WATER: usize = crate::mtracker::MAX_CACHE.as_usize() * MIN_MAX_PARTICIPANTS;
197        let min_low_water = MIN_LOW_WATER;
198        if *config.low_water < min_low_water {
199            return Err(ConfigBuildError::Invalid {
200                field: "low_water".into(),
201                problem: format!("must be at least {min_low_water}"),
202            });
203        }
204
205        let ratio: f32 = *config.low_water as f32 / *config.max as f32;
206        if ratio > MAX_LOW_WATER_RATIO {
207            return Err(ConfigBuildError::Inconsistent {
208                fields: vec!["low_water".into(), "max".into()],
209                problem: format!(
210                    "low_water / max = {ratio}; must be <= {MAX_LOW_WATER_RATIO}, ideally considerably lower"
211                ),
212            });
213        }
214
215        Ok(Config(IfEnabled::Enabled(config, enabled)))
216    }
217}
218
219/// Determine a max given the system's total available memory.
220///
221/// This is used when `max` is configured as "auto".
222/// It takes a `Result` so that we can handle the case where the total memory isn't available.
223fn compute_max_from_total_system_mem(mem: Result<usize, MemQueryError>) -> Qty {
224    const MIB: usize = 1024 * 1024;
225    const GIB: usize = 1024 * 1024 * 1024;
226
227    let mem = match mem {
228        Ok(x) => x,
229        Err(e) => {
230            warn!("Unable to get the total available memory. Using a constant max instead: {e}");
231
232            // Can't get the total available memory,
233            // so we return a max depending on whether the architecture is 32-bit or 64-bit.
234            return Qty({
235                cfg_if::cfg_if! {
236                    if #[cfg(target_pointer_width = "64")] {
237                        8 * GIB
238                    } else {
239                        1 * GIB
240                    }
241                }
242            });
243        }
244    };
245
246    let mem = {
247        // From c-tor:
248        //
249        // > The idea behind this value is that the amount of RAM is more than enough
250        // > for a single relay and should allow the relay operator to run two relays
251        // > if they have additional bandwidth available.
252        let mut factor = 0.75;
253        // Multiplying 8 * GIB overflows the usize limit (4 GIB - 1) on 32-bit
254        // platforms. So handle this properly for 32-bit platforms. Memory on 32-bit
255        // targets cannot exceed 4 GIB anyways.
256        #[cfg(target_pointer_width = "64")]
257        if mem >= 8 * GIB {
258            factor = 0.40;
259        }
260        (mem as f64 * factor) as usize
261    };
262
263    // The (min, max) range to clamp `mem` to.
264    let clamp = {
265        cfg_if::cfg_if! {
266            if #[cfg(target_pointer_width = "64")] {
267                (256 * MIB, 8 * GIB)
268            } else {
269                (256 * MIB, 2 * GIB)
270            }
271        }
272    };
273
274    let mem = mem.clamp(clamp.0, clamp.1);
275
276    Qty(mem)
277}
278
279/// The total available memory in bytes.
280///
281/// This is generally the amount of system RAM,
282/// but we may also take into account other OS-specific limits such as cgroups.
283///
284/// Returns `None` if we were unable to get the total available memory.
285/// But see internal comments for details.
286fn total_available_memory() -> Result<usize, MemQueryError> {
287    // The sysinfo crate says we should use only one `System` per application.
288    // But we're a library, so it's probably best to just make this global and reuse it.
289    // In reality getting the system memory probably shouldn't require persistent state,
290    // but since the internals of the sysinfo crate are opaque to us,
291    // we'll just follow their documentation and cache the `System`.
292    //
293    // NOTE: The sysinfo crate in practice gets more information than we ask for.
294    // For example `System::new()` will always query the `_SC_PAGESIZE` and `_SC_CLK_TCK`
295    // on Linux even though we only refresh the memory info below
296    // (see https://github.com/GuillaumeGomez/sysinfo/blob/fc31b411eea7b9983176399dc5be162786dec95b/src/unix/linux/system.rs#L152).
297    // This means that miri will fail to run on tests that build the config, even if the config uses
298    // explicit values.
299    static SYSTEM: LazyLock<Mutex<System>> = LazyLock::new(|| Mutex::new(System::new()));
300    let mut system = SYSTEM.lock().unwrap_or_else(|mut e| {
301        // The sysinfo crate has some internal panics which would poison this mutex.
302        // But we can easily reset it, rather than panicking ourselves if it's poisoned.
303        **e.get_mut() = System::new();
304        SYSTEM.clear_poison();
305        e.into_inner()
306    });
307
308    system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
309
310    // It might be possible for 32-bit systems to return >usize::MAX due to PAE (I haven't looked
311    // into this), so we just saturate the value and don't consider this an error.
312    let mem = to_usize_saturating(system.total_memory());
313
314    // The sysinfo crate doesn't report errors, so the best we can do is guess that a value of 0
315    // implies that it was unable to get the total memory.
316    //
317    // We also need to return early to prevent a panic below.
318    if mem == 0 {
319        return Err(MemQueryError::Unavailable);
320    }
321
322    // Note: The docs for the sysinfo crate say:
323    //
324    // > You need to have run refresh_memory at least once before calling this method.
325    //
326    // But as implemented, it also panics if `sys.mem_total == 0` (for example if the refresh
327    // silently failed).
328    let Some(cgroups) = system.cgroup_limits() else {
329        // There is no cgroup (or we're a non-Linux platform).
330        return Ok(mem);
331    };
332
333    // The `cgroup_limits()` surprisingly doesn't actually return the unaltered cgroups limits.
334    // It also adjusts them depending on the total memory.
335    // Since this is all undocumented, we'll also do the same calculation here.
336    let mem = std::cmp::min(mem, to_usize_saturating(cgroups.total_memory));
337
338    Ok(mem)
339}
340
341/// An error when we are unable to obtain the system's total available memory.
342#[derive(Clone, Debug, thiserror::Error)]
343enum MemQueryError {
344    /// The total available memory is unavailable.
345    #[error("total available memory is unavailable")]
346    Unavailable,
347}
348
349/// Convert a `u64` to a `usize`, saturating if the value would overflow.
350fn to_usize_saturating(x: u64) -> usize {
351    // this will be optimized to a no-op on 64-bit systems
352    x.try_into().unwrap_or(usize::MAX)
353}
354
355#[cfg(test)]
356mod test {
357    // @@ begin test lint list maintained by maint/add_warning @@
358    #![allow(clippy::bool_assert_comparison)]
359    #![allow(clippy::clone_on_copy)]
360    #![allow(clippy::dbg_macro)]
361    #![allow(clippy::mixed_attributes_style)]
362    #![allow(clippy::print_stderr)]
363    #![allow(clippy::print_stdout)]
364    #![allow(clippy::single_char_pattern)]
365    #![allow(clippy::unwrap_used)]
366    #![allow(clippy::unchecked_time_subtraction)]
367    #![allow(clippy::useless_vec)]
368    #![allow(clippy::needless_pass_by_value)]
369    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
370
371    use super::*;
372    use serde_json::json;
373
374    #[test]
375    // A value of "auto" depends on the system memory,
376    // which typically results in libc calls or syscall that aren't supported by miri.
377    #[cfg_attr(miri, ignore)]
378    fn configs() {
379        let chk_ok_raw = |j, c| {
380            let b: ConfigBuilder = serde_json::from_value(j).unwrap();
381            assert_eq!(b.build().unwrap(), c);
382        };
383        #[cfg(feature = "memquota")]
384        let chk_ok = |j, max, low_water| {
385            const M: usize = 1024 * 1024;
386
387            let exp = IfEnabled::Enabled(
388                ConfigInner {
389                    max: Qty(max * M),
390                    low_water: Qty(low_water * M),
391                },
392                EnabledToken::new(),
393            );
394
395            chk_ok_raw(j, Config(exp));
396        };
397        let chk_err = |j, exp| {
398            let b: ConfigBuilder = serde_json::from_value(j).unwrap();
399            let got = b.build().unwrap_err().to_string();
400
401            #[cfg(not(feature = "memquota"))]
402            if got.contains("cargo feature `memquota` disabled") {
403                return;
404            }
405
406            assert!(got.contains(exp), "in {exp:?} in {got:?}");
407        };
408        #[cfg(not(feature = "memquota"))]
409        let chk_ok = |j, max, low_water| {
410            chk_err(j, "UNSUPPORTED");
411        };
412
413        let chk_builds = |j| {
414            cfg_if::cfg_if! {
415                if #[cfg(feature = "memquota")] {
416                    let b: ConfigBuilder = serde_json::from_value(j).unwrap();
417                    b.build().unwrap();
418                } else {
419                    chk_err(j, "UNSUPPORTED");
420                }
421            }
422        };
423
424        chk_ok(json! {{ "max": "8 MiB" }}, 8, 6);
425        chk_ok(json! {{ "max": "8 MiB", "low_water": "auto" }}, 8, 6);
426        chk_ok(json! {{ "max": "8 MiB", "low_water": "4 MiB" }}, 8, 4);
427
428        // We don't know what the exact values will be since they are derived from the system
429        // memory.
430        chk_builds(json! {{ }});
431        chk_builds(json! {{ "max": "auto" }});
432        chk_builds(json! {{ "low_water": "auto" }});
433        chk_builds(json! {{ "max": "auto", "low_water": "auto" }});
434
435        chk_err(
436            json! {{ "low_water": "4 MiB" }},
437            "max is \"auto\", but low_water is set to an explicit quantity",
438        );
439        chk_err(
440            json! {{ "max": "8 MiB", "low_water": "8 MiB" }},
441            "inconsistent: low_water / max",
442        );
443
444        // `usize::MAX` is a special value.
445        chk_err(
446            json! {{ "max": usize::MAX.to_string(), "low_water": "8 MiB" }},
447            "low_water supplied, but max indicates that we should disable the memory quota",
448        );
449        chk_builds(json! {{ "max": (usize::MAX - 1).to_string(), "low_water": "8 MiB" }});
450
451        // check that the builder works as expected
452        #[cfg(feature = "memquota")]
453        {
454            let mut b = Config::builder();
455            b.max(ExplicitOrAuto::Explicit(100_000_000));
456            if let Some(inner) = b.build().unwrap().inner() {
457                assert_eq!(inner.max, Qty(100_000_000));
458            }
459
460            let mut b = Config::builder();
461            b.max(100_000_000);
462            if let Some(inner) = b.build().unwrap().inner() {
463                assert_eq!(inner.max, Qty(100_000_000));
464            }
465
466            let mut b = ConfigBuilder::default();
467            b.max(ExplicitOrAuto::Auto);
468            b.build().unwrap();
469        }
470    }
471
472    /// Test the logic that computes the `max` when configured as "auto".
473    #[test]
474    // We do some `1 * X` operations below for readability.
475    #[allow(clippy::identity_op)]
476    fn auto_max() {
477        #[allow(unused)]
478        fn check_helper(val: Qty, expected_32: Qty, expected_64: Qty) {
479            assert_eq!(val, {
480                cfg_if::cfg_if! {
481                    if #[cfg(target_pointer_width = "64")] {
482                        expected_64
483                    } else if #[cfg(target_pointer_width = "32")] {
484                        expected_32
485                    } else {
486                        panic!("Unsupported architecture :(");
487                    }
488                }
489            });
490        }
491
492        check_helper(
493            compute_max_from_total_system_mem(Err(MemQueryError::Unavailable)),
494            /* 32-bit */ Qty(1 * 1024 * 1024 * 1024),
495            /* 64-bit */ Qty(8 * 1024 * 1024 * 1024),
496        );
497        check_helper(
498            compute_max_from_total_system_mem(Ok(8 * 1024 * 1024 * 1024)),
499            /* 32-bit */ Qty(2 * 1024 * 1024 * 1024),
500            /* 64-bit */ Qty(3435973836),
501        );
502        check_helper(
503            compute_max_from_total_system_mem(Ok(7 * 1024 * 1024 * 1024)),
504            /* 32-bit */ Qty(2 * 1024 * 1024 * 1024),
505            /* 64-bit */ Qty(5637144576),
506        );
507        check_helper(
508            compute_max_from_total_system_mem(Ok(1 * 1024 * 1024 * 1024)),
509            /* 32-bit */ Qty(805306368),
510            /* 64-bit */ Qty(805306368),
511        );
512        check_helper(
513            compute_max_from_total_system_mem(Ok(7 * 1024)),
514            /* 32-bit */ Qty(256 * 1024 * 1024),
515            /* 64-bit */ Qty(256 * 1024 * 1024),
516        );
517        check_helper(
518            compute_max_from_total_system_mem(Ok(0)),
519            /* 32-bit */ Qty(256 * 1024 * 1024),
520            /* 64-bit */ Qty(256 * 1024 * 1024),
521        );
522        check_helper(
523            compute_max_from_total_system_mem(Ok(usize::MAX)),
524            /* 32-bit */ Qty(2 * 1024 * 1024 * 1024),
525            /* 64-bit */ Qty(8 * 1024 * 1024 * 1024),
526        );
527    }
528}