gam_runtime/test_support.rs
1//! Test-side backend for the diagnostics production already emits.
2//!
3//! Production instruments its solvers through the `log` facade — the BMS
4//! intercept-solve counters, the GL-ladder rung histogram, the cell-moment cache
5//! stats, the certificate-bound discriminator. Every one of those is a
6//! `log::info!`, and **the `log` facade drops every record until some binary
7//! installs a backend**. No test binary in this workspace installed one, so all
8//! of that instrumentation has been running and producing nothing, in unit tests
9//! and integration tests alike. Two lanes independently spent hours re-deriving
10//! facts these lines already carried (#2472's non-terminating marginal-slope
11//! cluster; the `[CERTIFICATE-BOUND]` discriminator). This module is the missing
12//! half.
13//!
14//! It lives in `gam-runtime` rather than `gam-test-support` for the reason that
15//! crate's own header gives: a helper owning no model-layer type belongs in a
16//! leaf, so depending on it does not drag the solver stack into an unrelated
17//! test build. `gam-runtime` already owns the observability modules (`span`,
18//! `process_monitor`, `loop_progress`) and already depends on `log`.
19//! `gam-test-support` re-exports it for consumers that use that path.
20//!
21//! Following the workspace convention for `test_support` modules, this is a
22//! plain always-compiled `pub mod`: a `cfg(test)` gate would make it invisible
23//! to exactly the downstream integration binaries that need it.
24
25use std::io::Write;
26use std::sync::Once;
27use std::sync::atomic::{AtomicUsize, Ordering};
28
29/// Records that could not be written to stderr.
30///
31/// A logger that silently fails to emit is the exact defect this module exists
32/// to remove, so the failures are counted rather than discarded — panicking
33/// inside `log::Log::log` is not an option (it runs from arbitrary call sites,
34/// including ones holding locks), but going quiet is what got us here.
35/// [`diagnostic_write_failures`] lets a caller assert its diagnostics actually
36/// reached the stream.
37static WRITE_FAILURES: AtomicUsize = AtomicUsize::new(0);
38
39/// Writes every record at or above `Info` straight to stderr, one line at a
40/// time, flushing each.
41///
42/// Per-record flushing is the whole point rather than an inefficiency: the
43/// diagnostics worth reading belong to runs that do not finish. A test killed at
44/// the per-test cap, or a fit abandoned after hours, must leave its trace
45/// behind — buffering it into a summary that never prints is how 17 core-hours
46/// produced zero lines.
47struct StderrDiagnosticLogger;
48
49impl log::Log for StderrDiagnosticLogger {
50 fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
51 metadata.level() <= log::Level::Info
52 }
53
54 fn log(&self, record: &log::Record<'_>) {
55 if !self.enabled(record.metadata()) {
56 return;
57 }
58 // `record.args()` is formatted through `Display`, never `Debug`: the
59 // ban scanner rejects `{:?}` in `eprintln!`/`eprint!`, and a debug-format
60 // diagnostic is unreadable anyway.
61 let mut stderr = std::io::stderr().lock();
62 let written = writeln!(stderr, "[{}] {}", record.level(), record.args());
63 // Flush per record: the diagnostics worth reading belong to runs that do
64 // not finish, so a buffered line is a lost line.
65 let flushed = stderr.flush();
66 if written.is_err() || flushed.is_err() {
67 WRITE_FAILURES.fetch_add(1, Ordering::Relaxed);
68 }
69 }
70
71 fn flush(&self) {
72 if std::io::stderr().flush().is_err() {
73 WRITE_FAILURES.fetch_add(1, Ordering::Relaxed);
74 }
75 }
76}
77
78static DIAGNOSTIC_LOGGER: StderrDiagnosticLogger = StderrDiagnosticLogger;
79static INSTALL_ONCE: Once = Once::new();
80
81/// Install the stderr diagnostic backend for this process, once.
82///
83/// Call it from any test that wants to read production's `log::info!` output.
84/// It is safe to call from every test in a binary and from several binaries at
85/// once: `log::set_logger` may only be called once per process, so the work is
86/// behind a [`Once`], and a losing call is treated as success — some other
87/// backend is already receiving the records, which is the outcome the caller
88/// wanted.
89///
90/// The level is chosen in code (`Info`) and is deliberately not configurable
91/// from the environment: the workspace bans environment-dependent behaviour, and
92/// a diagnostic you have to know an env var to see is one nobody sees.
93pub fn install_diagnostic_logger() {
94 INSTALL_ONCE.call_once(|| {
95 if log::set_logger(&DIAGNOSTIC_LOGGER).is_ok() {
96 log::set_max_level(log::LevelFilter::Info);
97 }
98 });
99}
100
101/// How many records this backend failed to write.
102///
103/// Nonzero means diagnostics were lost, which for a run being read for its
104/// instrumentation is a failed measurement rather than a cosmetic problem.
105pub fn diagnostic_write_failures() -> usize {
106 WRITE_FAILURES.load(Ordering::Relaxed)
107}
108
109/// Replay a cgroup-constrained memory environment without needing the machine
110/// to be in it.
111///
112/// The two knobs are deliberately the two that #2684 conflated:
113///
114/// * `cgroup_limit_bytes` is the job's hard ceiling — the CAPACITY. It is a
115/// property of how the process was launched and does not move while it runs.
116/// * `cgroup_current_bytes` is how much of that ceiling is charged RIGHT NOW —
117/// the load. It moves continuously, and on any cgroup that has done file I/O
118/// it sits near the ceiling because page cache fills whatever is free.
119///
120/// Holding the first fixed while sweeping the second is what lets a test ask
121/// "does this decision depend on ambient load?" as an assertion instead of an
122/// argument. The returned observation is built by the same constructor the live
123/// probe uses, so its derived `working_set`/`available` arithmetic is the
124/// shipped arithmetic and not a second implementation that could agree with the
125/// first only by luck.
126///
127/// The host numbers are taken as parameters too, because the host's own free
128/// memory is the other half of the conflation: the incident this exists for had
129/// hundreds of GB free on the host while the job's cgroup reported kilobytes.
130pub fn simulated_cgroup_memory_environment(
131 host_available_bytes: u64,
132 host_total_bytes: u64,
133 cgroup_limit_bytes: u64,
134 cgroup_current_bytes: u64,
135) -> crate::resource::MemoryAvailability {
136 let cgroup = crate::resource::CgroupMemoryAvailability::from_consistent_counters(
137 "/simulated/cgroup/memory",
138 cgroup_limit_bytes,
139 cgroup_current_bytes,
140 0,
141 6,
142 )
143 .expect("simulated cgroup counters must be internally consistent");
144 crate::resource::MemoryAvailability::from_observation(
145 host_available_bytes,
146 host_total_bytes,
147 crate::resource::CgroupMemoryObservation::V1Limited(cgroup),
148 )
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn install_is_idempotent_and_enables_info() {
157 install_diagnostic_logger();
158 install_diagnostic_logger();
159 // The facade only forwards records at or below the max level; if the
160 // install silently did nothing, production's `log::info!` diagnostics
161 // stay invisible and every caller of this module is misled.
162 assert!(
163 log::max_level() >= log::LevelFilter::Info,
164 "diagnostic logger must leave Info records enabled, got {}",
165 log::max_level()
166 );
167 assert!(log::log_enabled!(log::Level::Info));
168 }
169}