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
219impl tor_config::load::Builder for ConfigBuilder {
220 type Built = Config;
221
222 fn build(&self) -> Result<Self::Built, ConfigBuildError> {
223 ConfigBuilder::build(self)
224 }
225}
226
227impl tor_config::load::ConfigBuilder for ConfigBuilder {
228 fn apply_defaults(&mut self) -> Result<(), ConfigBuildError> {
229 self.max.get_or_insert_default();
230 self.low_water.get_or_insert_default();
231 Ok(())
232 }
233}
234
235/// Determine a max given the system's total available memory.
236///
237/// This is used when `max` is configured as "auto".
238/// It takes a `Result` so that we can handle the case where the total memory isn't available.
239fn compute_max_from_total_system_mem(mem: Result<usize, MemQueryError>) -> Qty {
240 const MIB: usize = 1024 * 1024;
241 const GIB: usize = 1024 * 1024 * 1024;
242
243 let mem = match mem {
244 Ok(x) => x,
245 Err(e) => {
246 warn!("Unable to get the total available memory. Using a constant max instead: {e}");
247
248 // Can't get the total available memory,
249 // so we return a max depending on whether the architecture is 32-bit or 64-bit.
250 return Qty({
251 cfg_if::cfg_if! {
252 if #[cfg(target_pointer_width = "64")] {
253 8 * GIB
254 } else {
255 1 * GIB
256 }
257 }
258 });
259 }
260 };
261
262 let mem = {
263 // From c-tor:
264 //
265 // > The idea behind this value is that the amount of RAM is more than enough
266 // > for a single relay and should allow the relay operator to run two relays
267 // > if they have additional bandwidth available.
268 let mut factor = 0.75;
269 // Multiplying 8 * GIB overflows the usize limit (4 GIB - 1) on 32-bit
270 // platforms. So handle this properly for 32-bit platforms. Memory on 32-bit
271 // targets cannot exceed 4 GIB anyways.
272 #[cfg(target_pointer_width = "64")]
273 if mem >= 8 * GIB {
274 factor = 0.40;
275 }
276 (mem as f64 * factor) as usize
277 };
278
279 // The (min, max) range to clamp `mem` to.
280 let clamp = {
281 cfg_if::cfg_if! {
282 if #[cfg(target_pointer_width = "64")] {
283 (256 * MIB, 8 * GIB)
284 } else {
285 (256 * MIB, 2 * GIB)
286 }
287 }
288 };
289
290 let mem = mem.clamp(clamp.0, clamp.1);
291
292 Qty(mem)
293}
294
295/// The total available memory in bytes.
296///
297/// This is generally the amount of system RAM,
298/// but we may also take into account other OS-specific limits such as cgroups.
299///
300/// Returns `None` if we were unable to get the total available memory.
301/// But see internal comments for details.
302fn total_available_memory() -> Result<usize, MemQueryError> {
303 // The sysinfo crate says we should use only one `System` per application.
304 // But we're a library, so it's probably best to just make this global and reuse it.
305 // In reality getting the system memory probably shouldn't require persistent state,
306 // but since the internals of the sysinfo crate are opaque to us,
307 // we'll just follow their documentation and cache the `System`.
308 //
309 // NOTE: The sysinfo crate in practice gets more information than we ask for.
310 // For example `System::new()` will always query the `_SC_PAGESIZE` and `_SC_CLK_TCK`
311 // on Linux even though we only refresh the memory info below
312 // (see https://github.com/GuillaumeGomez/sysinfo/blob/fc31b411eea7b9983176399dc5be162786dec95b/src/unix/linux/system.rs#L152).
313 // This means that miri will fail to run on tests that build the config, even if the config uses
314 // explicit values.
315 static SYSTEM: LazyLock<Mutex<System>> = LazyLock::new(|| Mutex::new(System::new()));
316 let mut system = SYSTEM.lock().unwrap_or_else(|mut e| {
317 // The sysinfo crate has some internal panics which would poison this mutex.
318 // But we can easily reset it, rather than panicking ourselves if it's poisoned.
319 **e.get_mut() = System::new();
320 SYSTEM.clear_poison();
321 e.into_inner()
322 });
323
324 system.refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram());
325
326 // It might be possible for 32-bit systems to return >usize::MAX due to PAE (I haven't looked
327 // into this), so we just saturate the value and don't consider this an error.
328 let mem = to_usize_saturating(system.total_memory());
329
330 // The sysinfo crate doesn't report errors, so the best we can do is guess that a value of 0
331 // implies that it was unable to get the total memory.
332 //
333 // We also need to return early to prevent a panic below.
334 if mem == 0 {
335 return Err(MemQueryError::Unavailable);
336 }
337
338 // Note: The docs for the sysinfo crate say:
339 //
340 // > You need to have run refresh_memory at least once before calling this method.
341 //
342 // But as implemented, it also panics if `sys.mem_total == 0` (for example if the refresh
343 // silently failed).
344 let Some(cgroups) = system.cgroup_limits() else {
345 // There is no cgroup (or we're a non-Linux platform).
346 return Ok(mem);
347 };
348
349 // The `cgroup_limits()` surprisingly doesn't actually return the unaltered cgroups limits.
350 // It also adjusts them depending on the total memory.
351 // Since this is all undocumented, we'll also do the same calculation here.
352 let mem = std::cmp::min(mem, to_usize_saturating(cgroups.total_memory));
353
354 Ok(mem)
355}
356
357/// An error when we are unable to obtain the system's total available memory.
358#[derive(Clone, Debug, thiserror::Error)]
359enum MemQueryError {
360 /// The total available memory is unavailable.
361 #[error("total available memory is unavailable")]
362 Unavailable,
363}
364
365/// Convert a `u64` to a `usize`, saturating if the value would overflow.
366fn to_usize_saturating(x: u64) -> usize {
367 // this will be optimized to a no-op on 64-bit systems
368 x.try_into().unwrap_or(usize::MAX)
369}
370
371#[cfg(test)]
372mod test {
373 // @@ begin test lint list maintained by maint/add_warning @@
374 #![allow(clippy::bool_assert_comparison)]
375 #![allow(clippy::clone_on_copy)]
376 #![allow(clippy::dbg_macro)]
377 #![allow(clippy::mixed_attributes_style)]
378 #![allow(clippy::print_stderr)]
379 #![allow(clippy::print_stdout)]
380 #![allow(clippy::single_char_pattern)]
381 #![allow(clippy::unwrap_used)]
382 #![allow(clippy::unchecked_time_subtraction)]
383 #![allow(clippy::useless_vec)]
384 #![allow(clippy::needless_pass_by_value)]
385 #![allow(clippy::string_slice)] // See arti#2571
386 //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
387
388 use super::*;
389 use serde_json::json;
390
391 #[test]
392 // A value of "auto" depends on the system memory,
393 // which typically results in libc calls or syscall that aren't supported by miri.
394 #[cfg_attr(miri, ignore)]
395 fn configs() {
396 let chk_ok_raw = |j, c| {
397 let b: ConfigBuilder = serde_json::from_value(j).unwrap();
398 assert_eq!(b.build().unwrap(), c);
399 };
400 #[cfg(feature = "memquota")]
401 let chk_ok = |j, max, low_water| {
402 const M: usize = 1024 * 1024;
403
404 let exp = IfEnabled::Enabled(
405 ConfigInner {
406 max: Qty(max * M),
407 low_water: Qty(low_water * M),
408 },
409 EnabledToken::new(),
410 );
411
412 chk_ok_raw(j, Config(exp));
413 };
414 let chk_err = |j, exp| {
415 let b: ConfigBuilder = serde_json::from_value(j).unwrap();
416 let got = b.build().unwrap_err().to_string();
417
418 #[cfg(not(feature = "memquota"))]
419 if got.contains("cargo feature `memquota` disabled") {
420 return;
421 }
422
423 assert!(got.contains(exp), "in {exp:?} in {got:?}");
424 };
425 #[cfg(not(feature = "memquota"))]
426 let chk_ok = |j, max, low_water| {
427 chk_err(j, "UNSUPPORTED");
428 };
429
430 let chk_builds = |j| {
431 cfg_if::cfg_if! {
432 if #[cfg(feature = "memquota")] {
433 let b: ConfigBuilder = serde_json::from_value(j).unwrap();
434 b.build().unwrap();
435 } else {
436 chk_err(j, "UNSUPPORTED");
437 }
438 }
439 };
440
441 chk_ok(json! {{ "max": "8 MiB" }}, 8, 6);
442 chk_ok(json! {{ "max": "8 MiB", "low_water": "auto" }}, 8, 6);
443 chk_ok(json! {{ "max": "8 MiB", "low_water": "4 MiB" }}, 8, 4);
444
445 // We don't know what the exact values will be since they are derived from the system
446 // memory.
447 chk_builds(json! {{ }});
448 chk_builds(json! {{ "max": "auto" }});
449 chk_builds(json! {{ "low_water": "auto" }});
450 chk_builds(json! {{ "max": "auto", "low_water": "auto" }});
451
452 chk_err(
453 json! {{ "low_water": "4 MiB" }},
454 "max is \"auto\", but low_water is set to an explicit quantity",
455 );
456 chk_err(
457 json! {{ "max": "8 MiB", "low_water": "8 MiB" }},
458 "inconsistent: low_water / max",
459 );
460
461 // `usize::MAX` is a special value.
462 chk_err(
463 json! {{ "max": usize::MAX.to_string(), "low_water": "8 MiB" }},
464 "low_water supplied, but max indicates that we should disable the memory quota",
465 );
466 chk_builds(json! {{ "max": (usize::MAX - 1).to_string(), "low_water": "8 MiB" }});
467
468 // check that the builder works as expected
469 #[cfg(feature = "memquota")]
470 {
471 let mut b = Config::builder();
472 b.max(ExplicitOrAuto::Explicit(100_000_000));
473 if let Some(inner) = b.build().unwrap().inner() {
474 assert_eq!(inner.max, Qty(100_000_000));
475 }
476
477 let mut b = Config::builder();
478 b.max(100_000_000);
479 if let Some(inner) = b.build().unwrap().inner() {
480 assert_eq!(inner.max, Qty(100_000_000));
481 }
482
483 let mut b = ConfigBuilder::default();
484 b.max(ExplicitOrAuto::Auto);
485 b.build().unwrap();
486 }
487 }
488
489 /// Test the logic that computes the `max` when configured as "auto".
490 #[test]
491 // We do some `1 * X` operations below for readability.
492 #[allow(clippy::identity_op)]
493 fn auto_max() {
494 #[allow(unused)]
495 fn check_helper(val: Qty, expected_32: Qty, expected_64: Qty) {
496 assert_eq!(val, {
497 cfg_if::cfg_if! {
498 if #[cfg(target_pointer_width = "64")] {
499 expected_64
500 } else if #[cfg(target_pointer_width = "32")] {
501 expected_32
502 } else {
503 panic!("Unsupported architecture :(");
504 }
505 }
506 });
507 }
508
509 check_helper(
510 compute_max_from_total_system_mem(Err(MemQueryError::Unavailable)),
511 /* 32-bit */ Qty(1 * 1024 * 1024 * 1024),
512 /* 64-bit */ Qty(8 * 1024 * 1024 * 1024),
513 );
514 check_helper(
515 compute_max_from_total_system_mem(Ok(8 * 1024 * 1024 * 1024)),
516 /* 32-bit */ Qty(2 * 1024 * 1024 * 1024),
517 /* 64-bit */ Qty(3435973836),
518 );
519 check_helper(
520 compute_max_from_total_system_mem(Ok(7 * 1024 * 1024 * 1024)),
521 /* 32-bit */ Qty(2 * 1024 * 1024 * 1024),
522 /* 64-bit */ Qty(5637144576),
523 );
524 check_helper(
525 compute_max_from_total_system_mem(Ok(1 * 1024 * 1024 * 1024)),
526 /* 32-bit */ Qty(805306368),
527 /* 64-bit */ Qty(805306368),
528 );
529 check_helper(
530 compute_max_from_total_system_mem(Ok(7 * 1024)),
531 /* 32-bit */ Qty(256 * 1024 * 1024),
532 /* 64-bit */ Qty(256 * 1024 * 1024),
533 );
534 check_helper(
535 compute_max_from_total_system_mem(Ok(0)),
536 /* 32-bit */ Qty(256 * 1024 * 1024),
537 /* 64-bit */ Qty(256 * 1024 * 1024),
538 );
539 check_helper(
540 compute_max_from_total_system_mem(Ok(usize::MAX)),
541 /* 32-bit */ Qty(2 * 1024 * 1024 * 1024),
542 /* 64-bit */ Qty(8 * 1024 * 1024 * 1024),
543 );
544 }
545}