Skip to main content

fdu_core/
counters.rs

1//! Low-distortion performance instrumentation for fdu.
2//!
3//! The subsystem has two complementary tiers:
4//!
5//! - application counters attribute logical work to the filesystem, memory, and index
6//!   layers;
7//! - [`process`] samples kernel counters at phase boundaries and exposes only metrics
8//!   supported by the current platform.
9//!
10//! Recording is compiled in, off by default, and enabled with `FDU_COUNTERS=1` by the
11//! shipped CLI and the repository performance probe. Per-event updates stay thread-local;
12//! workers fold their counts on exit, while a drop fallback preserves counts from callers
13//! that do not install an explicit worker guard.
14
15use std::cell::Cell;
16use std::ffi::OsStr;
17use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
18
19pub mod alloc;
20pub mod process;
21
22static ENABLED: AtomicBool = AtomicBool::new(false);
23
24/// Per-layer tallies for a walk, index build, and content pass.
25///
26/// Non-exhaustive, so a later counter is an additive change: outside this crate, build
27/// one with [`Counts::default`] and read or assign its fields, rather than with a struct
28/// literal or an exhaustive destructure (fdu-8f6k).
29#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct Counts {
32    /// Logical directory-open operations.
33    pub dir_opens: u64,
34    /// Enumeration syscalls in complete successful native listings — macOS bulk and
35    /// Linux `getdents64` — including the empty terminator.
36    ///
37    /// One successful directory is typically two calls (one data, one EOF). The portable
38    /// `read_dir` path leaves this at 0 because the standard library hides that split.
39    /// Abandoned native attempts before portable fallback are not counted, so this can be
40    /// compared to `dir_opens` without another sample.
41    pub dir_enumeration_calls: u64,
42    /// Directory entries yielded by enumeration.
43    pub dir_entries: u64,
44    /// Logical metadata-stat operations.
45    ///
46    /// The Linux native reader also counts the stat it makes for a `DT_UNKNOWN` directory
47    /// or symlink on the summary route; the portable path's `file_type` fallback makes
48    /// the same stat uncounted.
49    pub stats: u64,
50    /// File-open operations performed by content analysis.
51    pub file_opens: u64,
52    /// File read calls performed by content analysis.
53    pub file_reads: u64,
54    /// Bytes read from files by content analysis.
55    pub bytes_read: u64,
56    /// Allocation requests observed by the installed counting allocator.
57    pub allocs: u64,
58    /// Reallocation requests observed by the installed counting allocator.
59    pub reallocs: u64,
60    /// Deallocations observed by the installed counting allocator.
61    pub frees: u64,
62    /// Bytes requested by allocations and allocation growth.
63    pub bytes_allocated: u64,
64    /// Index upserts applied.
65    pub upserts: u64,
66    /// Parent-path resolutions performed for index updates.
67    pub parent_resolutions: u64,
68    /// Parent resolutions served by the adjacent-parent memo.
69    pub parent_memo_hits: u64,
70    /// Roll-up merge operations.
71    pub rollup_merges: u64,
72    /// Control files read from the filesystem.
73    pub control_reads: u64,
74    /// Control sources a control table refused for one of its limits.
75    ///
76    /// Counted where the table decides, so a re-read of a file it has already refused is
77    /// not: that batch cannot change the table and is never projected. A warm revalidate
78    /// of a refused tree therefore reports its reads against no refusals, which is the
79    /// projection being skipped rather than a refusal being lifted.
80    pub control_refused: u64,
81    /// Control sources that joined a retained identical content instead of parsing again.
82    pub control_sources_shared: u64,
83    /// `.gitignore` rules whose glob was tested against an entry: the rules an entry met
84    /// that no lookup or cheap check on its name could decide (H171).
85    pub ignore_patterns_tested: u64,
86    /// Lookups of an entry's name in a `.gitignore` file's indexed rules: one per
87    /// literal-name table and extension table consulted, and one per ends-with rule
88    /// without a `.` compared.
89    pub ignore_bucket_probes: u64,
90    /// Those lookups that found a rule matching the entry.
91    pub ignore_bucket_hits: u64,
92    /// Index entries allocated.
93    pub entries_allocated: u64,
94    /// Successful detached baseline batches arbitrated by the index.
95    pub baseline_batches: u64,
96    /// Operations accepted from successful detached baseline batches.
97    pub baseline_accepted_ops: u64,
98    /// Successful opened-root batches arbitrated by the index.
99    pub opened_batches: u64,
100    /// Operations accepted from successful opened-root batches.
101    pub opened_accepted_ops: u64,
102    /// Successful arbitrary public batches arbitrated by the index.
103    pub public_batches: u64,
104    /// Operations accepted from successful arbitrary public batches.
105    pub public_accepted_ops: u64,
106    /// Path-keyed structural-overlay inserts during ancestry preflight.
107    pub ancestry_overlay_inserts: u64,
108    /// Same-parent path comparisons during ancestry preflight.
109    pub ancestry_path_comparisons: u64,
110    /// Parent chains proved during ancestry preflight, including memo hits.
111    pub ancestry_parent_proofs: u64,
112    /// Wall microseconds spent preparing trusted scanner batches.
113    pub scanner_prepare_us: u64,
114    /// Wall microseconds spent projecting controls for trusted scanner batches.
115    pub scanner_control_projection_us: u64,
116    /// Wall microseconds spent reducing prepared scanner batches into the index.
117    pub scanner_reduce_us: u64,
118    /// Wall microseconds spent reading the content sidecar image.
119    pub content_sidecar_read_us: u64,
120    /// Wall microseconds spent on sidecar integrity and record decode.
121    pub content_sidecar_parse_us: u64,
122    /// Wall microseconds spent building the live candidate map for a sidecar restore.
123    pub content_sidecar_candidates_us: u64,
124    /// Wall microseconds spent applying restored records and rebuilding content roll-ups.
125    pub content_sidecar_apply_us: u64,
126    /// Detached cold scans that selected the directory-group builder.
127    pub detached_builds: u64,
128    /// Entries consumed by that builder.
129    pub detached_entries: u64,
130    /// Wall microseconds spent walking while the builder consumed directory groups.
131    pub detached_walk_us: u64,
132    /// Wall microseconds spent in the final directories-only roll-up pass.
133    pub detached_finish_us: u64,
134    /// Owned paths retained in exact effective changes.
135    pub effect_paths: u64,
136    /// Native encoded bytes retained by exact effective-change paths.
137    pub effect_path_bytes: u64,
138    /// Change and state paths considered for bounded impact derivation.
139    pub impact_candidates: u64,
140    /// Path ancestors visited while deriving bounded impact.
141    pub impact_ancestor_visits: u64,
142    /// Dirty paths retained in completed bounded impacts.
143    pub impact_retained_dirty_paths: u64,
144    /// Impact derivations that crossed the dirty-path bound.
145    pub impact_all_dirty: u64,
146    /// Exact commits retained in the bounded journal.
147    pub journal_retained_commits: u64,
148    /// Exact commits cloned for attempted journal retention.
149    pub journal_cloned_commits: u64,
150    /// Exact commits rejected because one commit exceeded journal capacity.
151    pub journal_oversized_commits: u64,
152    /// Older exact commits dropped to admit a new retained commit.
153    pub journal_dropped_commits: u64,
154    /// Chunk releases folded into an automatic scan's worker-scaling calibration.
155    pub adaptive_calibration_chunks: u64,
156    /// Entries folded into that calibration before it reached a decision.
157    pub adaptive_calibration_entries: u64,
158    /// Worker time, in microseconds, folded into that calibration.
159    ///
160    /// Microseconds rather than nanoseconds because the decision threshold is expressed
161    /// per entry in microseconds, and a nanosecond total over a multi-million-entry walk
162    /// is large enough to make the row hard to read.
163    pub adaptive_calibration_work_us: u64,
164    /// Reserve-pool expansions an automatic scan actually performed.
165    pub adaptive_scale_ups: u64,
166    /// Walks that ended while their scaling calibration was still undecided.
167    ///
168    /// A walk shorter than the calibration window never observes enough entries to
169    /// decide, so its worker policy is unobservable rather than merely unchanged. That
170    /// is the distinction [`crate::scan`] fails closed on, and it must not read as a
171    /// decision to hold.
172    pub adaptive_policy_undecided: u64,
173}
174
175impl Counts {
176    const ZERO: Self = Self {
177        dir_opens: 0,
178        dir_enumeration_calls: 0,
179        dir_entries: 0,
180        stats: 0,
181        file_opens: 0,
182        file_reads: 0,
183        bytes_read: 0,
184        allocs: 0,
185        reallocs: 0,
186        frees: 0,
187        bytes_allocated: 0,
188        upserts: 0,
189        parent_resolutions: 0,
190        parent_memo_hits: 0,
191        rollup_merges: 0,
192        control_reads: 0,
193        control_refused: 0,
194        control_sources_shared: 0,
195        ignore_patterns_tested: 0,
196        ignore_bucket_probes: 0,
197        ignore_bucket_hits: 0,
198        entries_allocated: 0,
199        baseline_batches: 0,
200        baseline_accepted_ops: 0,
201        opened_batches: 0,
202        opened_accepted_ops: 0,
203        public_batches: 0,
204        public_accepted_ops: 0,
205        ancestry_overlay_inserts: 0,
206        ancestry_path_comparisons: 0,
207        ancestry_parent_proofs: 0,
208        scanner_prepare_us: 0,
209        scanner_control_projection_us: 0,
210        scanner_reduce_us: 0,
211        content_sidecar_read_us: 0,
212        content_sidecar_parse_us: 0,
213        content_sidecar_candidates_us: 0,
214        content_sidecar_apply_us: 0,
215        detached_builds: 0,
216        detached_entries: 0,
217        detached_walk_us: 0,
218        detached_finish_us: 0,
219        effect_paths: 0,
220        effect_path_bytes: 0,
221        impact_candidates: 0,
222        impact_ancestor_visits: 0,
223        impact_retained_dirty_paths: 0,
224        impact_all_dirty: 0,
225        journal_retained_commits: 0,
226        journal_cloned_commits: 0,
227        journal_oversized_commits: 0,
228        journal_dropped_commits: 0,
229        adaptive_calibration_chunks: 0,
230        adaptive_calibration_entries: 0,
231        adaptive_calibration_work_us: 0,
232        adaptive_scale_ups: 0,
233        adaptive_policy_undecided: 0,
234    };
235
236    /// Every counter as `(group, label, value)`, in report order.
237    #[must_use]
238    pub fn rows(&self) -> Vec<(&'static str, &'static str, u64)> {
239        vec![
240            ("filesystem operations", "directory opens", self.dir_opens),
241            ("filesystem operations", "directory enumeration calls", self.dir_enumeration_calls),
242            ("filesystem operations", "directory entries enumerated", self.dir_entries),
243            ("filesystem operations", "metadata stats", self.stats),
244            ("filesystem operations", "file opens", self.file_opens),
245            ("filesystem operations", "file read calls", self.file_reads),
246            ("filesystem operations", "bytes read from files", self.bytes_read),
247            ("memory", "allocations", self.allocs),
248            ("memory", "reallocations", self.reallocs),
249            ("memory", "frees", self.frees),
250            ("memory", "bytes allocated", self.bytes_allocated),
251            ("index", "upserts applied", self.upserts),
252            ("index", "parent path resolutions", self.parent_resolutions),
253            ("index", "parent memo hits", self.parent_memo_hits),
254            ("index", "roll-up merges", self.rollup_merges),
255            ("control state", "control files read", self.control_reads),
256            ("control state", "control sources refused", self.control_refused),
257            ("control state", "control sources shared", self.control_sources_shared),
258            ("control state", "ignore patterns tested", self.ignore_patterns_tested),
259            ("control state", "ignore bucket probes", self.ignore_bucket_probes),
260            ("control state", "ignore bucket hits", self.ignore_bucket_hits),
261            ("index", "index entries allocated", self.entries_allocated),
262            ("mutation provenance", "baseline batches", self.baseline_batches),
263            ("mutation provenance", "baseline accepted ops", self.baseline_accepted_ops),
264            ("mutation provenance", "opened batches", self.opened_batches),
265            ("mutation provenance", "opened accepted ops", self.opened_accepted_ops),
266            ("mutation provenance", "public batches", self.public_batches),
267            ("mutation provenance", "public accepted ops", self.public_accepted_ops),
268            ("mutation preflight", "ancestry overlay inserts", self.ancestry_overlay_inserts),
269            ("mutation preflight", "same-parent path comparisons", self.ancestry_path_comparisons),
270            ("mutation preflight", "parent chains proved", self.ancestry_parent_proofs),
271            ("mutation timing", "scanner preparation microseconds", self.scanner_prepare_us),
272            (
273                "mutation timing",
274                "scanner control projection microseconds",
275                self.scanner_control_projection_us,
276            ),
277            ("mutation timing", "scanner reduction microseconds", self.scanner_reduce_us),
278            (
279                "content sidecar timing",
280                "sidecar image read microseconds",
281                self.content_sidecar_read_us,
282            ),
283            ("content sidecar timing", "sidecar parse microseconds", self.content_sidecar_parse_us),
284            (
285                "content sidecar timing",
286                "sidecar candidate-map microseconds",
287                self.content_sidecar_candidates_us,
288            ),
289            ("content sidecar timing", "sidecar apply microseconds", self.content_sidecar_apply_us),
290            ("detached builder", "builds", self.detached_builds),
291            ("detached builder", "entries", self.detached_entries),
292            ("detached builder", "walk microseconds", self.detached_walk_us),
293            ("detached builder", "finish microseconds", self.detached_finish_us),
294            ("mutation consequences", "effective paths retained", self.effect_paths),
295            ("mutation consequences", "effective path bytes", self.effect_path_bytes),
296            ("mutation consequences", "impact candidates", self.impact_candidates),
297            ("mutation consequences", "impact ancestor visits", self.impact_ancestor_visits),
298            (
299                "mutation consequences",
300                "impact dirty paths retained",
301                self.impact_retained_dirty_paths,
302            ),
303            ("mutation consequences", "impact all-dirty transitions", self.impact_all_dirty),
304            ("mutation journal", "commits retained", self.journal_retained_commits),
305            ("mutation journal", "commits cloned", self.journal_cloned_commits),
306            ("mutation journal", "oversized commits", self.journal_oversized_commits),
307            ("mutation journal", "older commits dropped", self.journal_dropped_commits),
308            ("adaptive scan policy", "calibration chunks", self.adaptive_calibration_chunks),
309            ("adaptive scan policy", "calibration entries", self.adaptive_calibration_entries),
310            (
311                "adaptive scan policy",
312                "calibration worker microseconds",
313                self.adaptive_calibration_work_us,
314            ),
315            ("adaptive scan policy", "reserve expansions", self.adaptive_scale_ups),
316            ("adaptive scan policy", "walks left undecided", self.adaptive_policy_undecided),
317        ]
318    }
319
320    /// Whether every counter is zero.
321    #[must_use]
322    pub fn is_empty(&self) -> bool {
323        *self == Self::default()
324    }
325
326    /// Fold another set into this one without panicking on overflow.
327    fn add(&mut self, other: &Self) {
328        self.dir_opens = self.dir_opens.saturating_add(other.dir_opens);
329        self.dir_enumeration_calls =
330            self.dir_enumeration_calls.saturating_add(other.dir_enumeration_calls);
331        self.dir_entries = self.dir_entries.saturating_add(other.dir_entries);
332        self.stats = self.stats.saturating_add(other.stats);
333        self.file_opens = self.file_opens.saturating_add(other.file_opens);
334        self.file_reads = self.file_reads.saturating_add(other.file_reads);
335        self.bytes_read = self.bytes_read.saturating_add(other.bytes_read);
336        self.allocs = self.allocs.saturating_add(other.allocs);
337        self.reallocs = self.reallocs.saturating_add(other.reallocs);
338        self.frees = self.frees.saturating_add(other.frees);
339        self.bytes_allocated = self.bytes_allocated.saturating_add(other.bytes_allocated);
340        self.upserts = self.upserts.saturating_add(other.upserts);
341        self.parent_resolutions = self.parent_resolutions.saturating_add(other.parent_resolutions);
342        self.parent_memo_hits = self.parent_memo_hits.saturating_add(other.parent_memo_hits);
343        self.rollup_merges = self.rollup_merges.saturating_add(other.rollup_merges);
344        self.control_reads = self.control_reads.saturating_add(other.control_reads);
345        self.control_refused = self.control_refused.saturating_add(other.control_refused);
346        self.control_sources_shared =
347            self.control_sources_shared.saturating_add(other.control_sources_shared);
348        self.ignore_patterns_tested =
349            self.ignore_patterns_tested.saturating_add(other.ignore_patterns_tested);
350        self.ignore_bucket_probes =
351            self.ignore_bucket_probes.saturating_add(other.ignore_bucket_probes);
352        self.ignore_bucket_hits = self.ignore_bucket_hits.saturating_add(other.ignore_bucket_hits);
353        self.entries_allocated = self.entries_allocated.saturating_add(other.entries_allocated);
354        self.baseline_batches = self.baseline_batches.saturating_add(other.baseline_batches);
355        self.baseline_accepted_ops =
356            self.baseline_accepted_ops.saturating_add(other.baseline_accepted_ops);
357        self.opened_batches = self.opened_batches.saturating_add(other.opened_batches);
358        self.opened_accepted_ops =
359            self.opened_accepted_ops.saturating_add(other.opened_accepted_ops);
360        self.public_batches = self.public_batches.saturating_add(other.public_batches);
361        self.public_accepted_ops =
362            self.public_accepted_ops.saturating_add(other.public_accepted_ops);
363        self.ancestry_overlay_inserts =
364            self.ancestry_overlay_inserts.saturating_add(other.ancestry_overlay_inserts);
365        self.ancestry_path_comparisons =
366            self.ancestry_path_comparisons.saturating_add(other.ancestry_path_comparisons);
367        self.ancestry_parent_proofs =
368            self.ancestry_parent_proofs.saturating_add(other.ancestry_parent_proofs);
369        self.scanner_prepare_us = self.scanner_prepare_us.saturating_add(other.scanner_prepare_us);
370        self.scanner_control_projection_us =
371            self.scanner_control_projection_us.saturating_add(other.scanner_control_projection_us);
372        self.scanner_reduce_us = self.scanner_reduce_us.saturating_add(other.scanner_reduce_us);
373        self.content_sidecar_read_us =
374            self.content_sidecar_read_us.saturating_add(other.content_sidecar_read_us);
375        self.content_sidecar_parse_us =
376            self.content_sidecar_parse_us.saturating_add(other.content_sidecar_parse_us);
377        self.content_sidecar_candidates_us =
378            self.content_sidecar_candidates_us.saturating_add(other.content_sidecar_candidates_us);
379        self.content_sidecar_apply_us =
380            self.content_sidecar_apply_us.saturating_add(other.content_sidecar_apply_us);
381        self.detached_builds = self.detached_builds.saturating_add(other.detached_builds);
382        self.detached_entries = self.detached_entries.saturating_add(other.detached_entries);
383        self.detached_walk_us = self.detached_walk_us.saturating_add(other.detached_walk_us);
384        self.detached_finish_us = self.detached_finish_us.saturating_add(other.detached_finish_us);
385        self.effect_paths = self.effect_paths.saturating_add(other.effect_paths);
386        self.effect_path_bytes = self.effect_path_bytes.saturating_add(other.effect_path_bytes);
387        self.impact_candidates = self.impact_candidates.saturating_add(other.impact_candidates);
388        self.impact_ancestor_visits =
389            self.impact_ancestor_visits.saturating_add(other.impact_ancestor_visits);
390        self.impact_retained_dirty_paths =
391            self.impact_retained_dirty_paths.saturating_add(other.impact_retained_dirty_paths);
392        self.impact_all_dirty = self.impact_all_dirty.saturating_add(other.impact_all_dirty);
393        self.journal_retained_commits =
394            self.journal_retained_commits.saturating_add(other.journal_retained_commits);
395        self.journal_cloned_commits =
396            self.journal_cloned_commits.saturating_add(other.journal_cloned_commits);
397        self.journal_oversized_commits =
398            self.journal_oversized_commits.saturating_add(other.journal_oversized_commits);
399        self.journal_dropped_commits =
400            self.journal_dropped_commits.saturating_add(other.journal_dropped_commits);
401        self.adaptive_calibration_chunks =
402            self.adaptive_calibration_chunks.saturating_add(other.adaptive_calibration_chunks);
403        self.adaptive_calibration_entries =
404            self.adaptive_calibration_entries.saturating_add(other.adaptive_calibration_entries);
405        self.adaptive_calibration_work_us =
406            self.adaptive_calibration_work_us.saturating_add(other.adaptive_calibration_work_us);
407        self.adaptive_scale_ups = self.adaptive_scale_ups.saturating_add(other.adaptive_scale_ups);
408        self.adaptive_policy_undecided =
409            self.adaptive_policy_undecided.saturating_add(other.adaptive_policy_undecided);
410    }
411
412    /// Per-event ratios against a denominator, in report order.
413    #[must_use]
414    pub fn per(&self, denominator: u64) -> Vec<(&'static str, &'static str, f64)> {
415        self.rows()
416            .into_iter()
417            .map(|(group, label, value)| (group, label, ratio(value, denominator)))
418            .collect()
419    }
420}
421
422struct GlobalCounts {
423    dir_opens: AtomicU64,
424    dir_enumeration_calls: AtomicU64,
425    dir_entries: AtomicU64,
426    stats: AtomicU64,
427    file_opens: AtomicU64,
428    file_reads: AtomicU64,
429    bytes_read: AtomicU64,
430    allocs: AtomicU64,
431    reallocs: AtomicU64,
432    frees: AtomicU64,
433    bytes_allocated: AtomicU64,
434    upserts: AtomicU64,
435    parent_resolutions: AtomicU64,
436    parent_memo_hits: AtomicU64,
437    rollup_merges: AtomicU64,
438    control_reads: AtomicU64,
439    control_refused: AtomicU64,
440    control_sources_shared: AtomicU64,
441    ignore_patterns_tested: AtomicU64,
442    ignore_bucket_probes: AtomicU64,
443    ignore_bucket_hits: AtomicU64,
444    entries_allocated: AtomicU64,
445    baseline_batches: AtomicU64,
446    baseline_accepted_ops: AtomicU64,
447    opened_batches: AtomicU64,
448    opened_accepted_ops: AtomicU64,
449    public_batches: AtomicU64,
450    public_accepted_ops: AtomicU64,
451    ancestry_overlay_inserts: AtomicU64,
452    ancestry_path_comparisons: AtomicU64,
453    ancestry_parent_proofs: AtomicU64,
454    scanner_prepare_us: AtomicU64,
455    scanner_control_projection_us: AtomicU64,
456    scanner_reduce_us: AtomicU64,
457    content_sidecar_read_us: AtomicU64,
458    content_sidecar_parse_us: AtomicU64,
459    content_sidecar_candidates_us: AtomicU64,
460    content_sidecar_apply_us: AtomicU64,
461    detached_builds: AtomicU64,
462    detached_entries: AtomicU64,
463    detached_walk_us: AtomicU64,
464    detached_finish_us: AtomicU64,
465    effect_paths: AtomicU64,
466    effect_path_bytes: AtomicU64,
467    impact_candidates: AtomicU64,
468    impact_ancestor_visits: AtomicU64,
469    impact_retained_dirty_paths: AtomicU64,
470    impact_all_dirty: AtomicU64,
471    journal_retained_commits: AtomicU64,
472    journal_cloned_commits: AtomicU64,
473    journal_oversized_commits: AtomicU64,
474    journal_dropped_commits: AtomicU64,
475    adaptive_calibration_chunks: AtomicU64,
476    adaptive_calibration_entries: AtomicU64,
477    adaptive_calibration_work_us: AtomicU64,
478    adaptive_scale_ups: AtomicU64,
479    adaptive_policy_undecided: AtomicU64,
480}
481
482impl GlobalCounts {
483    const fn new() -> Self {
484        Self {
485            dir_opens: AtomicU64::new(0),
486            dir_enumeration_calls: AtomicU64::new(0),
487            dir_entries: AtomicU64::new(0),
488            stats: AtomicU64::new(0),
489            file_opens: AtomicU64::new(0),
490            file_reads: AtomicU64::new(0),
491            bytes_read: AtomicU64::new(0),
492            allocs: AtomicU64::new(0),
493            reallocs: AtomicU64::new(0),
494            frees: AtomicU64::new(0),
495            bytes_allocated: AtomicU64::new(0),
496            upserts: AtomicU64::new(0),
497            parent_resolutions: AtomicU64::new(0),
498            parent_memo_hits: AtomicU64::new(0),
499            rollup_merges: AtomicU64::new(0),
500            control_reads: AtomicU64::new(0),
501            control_refused: AtomicU64::new(0),
502            control_sources_shared: AtomicU64::new(0),
503            ignore_patterns_tested: AtomicU64::new(0),
504            ignore_bucket_probes: AtomicU64::new(0),
505            ignore_bucket_hits: AtomicU64::new(0),
506            entries_allocated: AtomicU64::new(0),
507            baseline_batches: AtomicU64::new(0),
508            baseline_accepted_ops: AtomicU64::new(0),
509            opened_batches: AtomicU64::new(0),
510            opened_accepted_ops: AtomicU64::new(0),
511            public_batches: AtomicU64::new(0),
512            public_accepted_ops: AtomicU64::new(0),
513            ancestry_overlay_inserts: AtomicU64::new(0),
514            ancestry_path_comparisons: AtomicU64::new(0),
515            ancestry_parent_proofs: AtomicU64::new(0),
516            scanner_prepare_us: AtomicU64::new(0),
517            scanner_control_projection_us: AtomicU64::new(0),
518            scanner_reduce_us: AtomicU64::new(0),
519            content_sidecar_read_us: AtomicU64::new(0),
520            content_sidecar_parse_us: AtomicU64::new(0),
521            content_sidecar_candidates_us: AtomicU64::new(0),
522            content_sidecar_apply_us: AtomicU64::new(0),
523            detached_builds: AtomicU64::new(0),
524            detached_entries: AtomicU64::new(0),
525            detached_walk_us: AtomicU64::new(0),
526            detached_finish_us: AtomicU64::new(0),
527            effect_paths: AtomicU64::new(0),
528            effect_path_bytes: AtomicU64::new(0),
529            impact_candidates: AtomicU64::new(0),
530            impact_ancestor_visits: AtomicU64::new(0),
531            impact_retained_dirty_paths: AtomicU64::new(0),
532            impact_all_dirty: AtomicU64::new(0),
533            journal_retained_commits: AtomicU64::new(0),
534            journal_cloned_commits: AtomicU64::new(0),
535            journal_oversized_commits: AtomicU64::new(0),
536            journal_dropped_commits: AtomicU64::new(0),
537            adaptive_calibration_chunks: AtomicU64::new(0),
538            adaptive_calibration_entries: AtomicU64::new(0),
539            adaptive_calibration_work_us: AtomicU64::new(0),
540            adaptive_scale_ups: AtomicU64::new(0),
541            adaptive_policy_undecided: AtomicU64::new(0),
542        }
543    }
544
545    fn add(&self, counts: &Counts) {
546        atomic_saturating_add(&self.dir_opens, counts.dir_opens);
547        atomic_saturating_add(&self.dir_enumeration_calls, counts.dir_enumeration_calls);
548        atomic_saturating_add(&self.dir_entries, counts.dir_entries);
549        atomic_saturating_add(&self.stats, counts.stats);
550        atomic_saturating_add(&self.file_opens, counts.file_opens);
551        atomic_saturating_add(&self.file_reads, counts.file_reads);
552        atomic_saturating_add(&self.bytes_read, counts.bytes_read);
553        atomic_saturating_add(&self.allocs, counts.allocs);
554        atomic_saturating_add(&self.reallocs, counts.reallocs);
555        atomic_saturating_add(&self.frees, counts.frees);
556        atomic_saturating_add(&self.bytes_allocated, counts.bytes_allocated);
557        atomic_saturating_add(&self.upserts, counts.upserts);
558        atomic_saturating_add(&self.parent_resolutions, counts.parent_resolutions);
559        atomic_saturating_add(&self.parent_memo_hits, counts.parent_memo_hits);
560        atomic_saturating_add(&self.rollup_merges, counts.rollup_merges);
561        atomic_saturating_add(&self.control_reads, counts.control_reads);
562        atomic_saturating_add(&self.control_refused, counts.control_refused);
563        atomic_saturating_add(&self.control_sources_shared, counts.control_sources_shared);
564        atomic_saturating_add(&self.ignore_patterns_tested, counts.ignore_patterns_tested);
565        atomic_saturating_add(&self.ignore_bucket_probes, counts.ignore_bucket_probes);
566        atomic_saturating_add(&self.ignore_bucket_hits, counts.ignore_bucket_hits);
567        atomic_saturating_add(&self.entries_allocated, counts.entries_allocated);
568        atomic_saturating_add(&self.baseline_batches, counts.baseline_batches);
569        atomic_saturating_add(&self.baseline_accepted_ops, counts.baseline_accepted_ops);
570        atomic_saturating_add(&self.opened_batches, counts.opened_batches);
571        atomic_saturating_add(&self.opened_accepted_ops, counts.opened_accepted_ops);
572        atomic_saturating_add(&self.public_batches, counts.public_batches);
573        atomic_saturating_add(&self.public_accepted_ops, counts.public_accepted_ops);
574        atomic_saturating_add(&self.ancestry_overlay_inserts, counts.ancestry_overlay_inserts);
575        atomic_saturating_add(&self.ancestry_path_comparisons, counts.ancestry_path_comparisons);
576        atomic_saturating_add(&self.ancestry_parent_proofs, counts.ancestry_parent_proofs);
577        atomic_saturating_add(&self.scanner_prepare_us, counts.scanner_prepare_us);
578        atomic_saturating_add(
579            &self.scanner_control_projection_us,
580            counts.scanner_control_projection_us,
581        );
582        atomic_saturating_add(&self.scanner_reduce_us, counts.scanner_reduce_us);
583        atomic_saturating_add(&self.content_sidecar_read_us, counts.content_sidecar_read_us);
584        atomic_saturating_add(&self.content_sidecar_parse_us, counts.content_sidecar_parse_us);
585        atomic_saturating_add(
586            &self.content_sidecar_candidates_us,
587            counts.content_sidecar_candidates_us,
588        );
589        atomic_saturating_add(&self.content_sidecar_apply_us, counts.content_sidecar_apply_us);
590        atomic_saturating_add(&self.detached_builds, counts.detached_builds);
591        atomic_saturating_add(&self.detached_entries, counts.detached_entries);
592        atomic_saturating_add(&self.detached_walk_us, counts.detached_walk_us);
593        atomic_saturating_add(&self.detached_finish_us, counts.detached_finish_us);
594        atomic_saturating_add(&self.effect_paths, counts.effect_paths);
595        atomic_saturating_add(&self.effect_path_bytes, counts.effect_path_bytes);
596        atomic_saturating_add(&self.impact_candidates, counts.impact_candidates);
597        atomic_saturating_add(&self.impact_ancestor_visits, counts.impact_ancestor_visits);
598        atomic_saturating_add(
599            &self.impact_retained_dirty_paths,
600            counts.impact_retained_dirty_paths,
601        );
602        atomic_saturating_add(&self.impact_all_dirty, counts.impact_all_dirty);
603        atomic_saturating_add(&self.journal_retained_commits, counts.journal_retained_commits);
604        atomic_saturating_add(&self.journal_cloned_commits, counts.journal_cloned_commits);
605        atomic_saturating_add(&self.journal_oversized_commits, counts.journal_oversized_commits);
606        atomic_saturating_add(&self.journal_dropped_commits, counts.journal_dropped_commits);
607        atomic_saturating_add(
608            &self.adaptive_calibration_chunks,
609            counts.adaptive_calibration_chunks,
610        );
611        atomic_saturating_add(
612            &self.adaptive_calibration_entries,
613            counts.adaptive_calibration_entries,
614        );
615        atomic_saturating_add(
616            &self.adaptive_calibration_work_us,
617            counts.adaptive_calibration_work_us,
618        );
619        atomic_saturating_add(&self.adaptive_scale_ups, counts.adaptive_scale_ups);
620        atomic_saturating_add(&self.adaptive_policy_undecided, counts.adaptive_policy_undecided);
621    }
622
623    fn snapshot(&self) -> Counts {
624        Counts {
625            dir_opens: self.dir_opens.load(Ordering::Relaxed),
626            dir_enumeration_calls: self.dir_enumeration_calls.load(Ordering::Relaxed),
627            dir_entries: self.dir_entries.load(Ordering::Relaxed),
628            stats: self.stats.load(Ordering::Relaxed),
629            file_opens: self.file_opens.load(Ordering::Relaxed),
630            file_reads: self.file_reads.load(Ordering::Relaxed),
631            bytes_read: self.bytes_read.load(Ordering::Relaxed),
632            allocs: self.allocs.load(Ordering::Relaxed),
633            reallocs: self.reallocs.load(Ordering::Relaxed),
634            frees: self.frees.load(Ordering::Relaxed),
635            bytes_allocated: self.bytes_allocated.load(Ordering::Relaxed),
636            upserts: self.upserts.load(Ordering::Relaxed),
637            parent_resolutions: self.parent_resolutions.load(Ordering::Relaxed),
638            parent_memo_hits: self.parent_memo_hits.load(Ordering::Relaxed),
639            rollup_merges: self.rollup_merges.load(Ordering::Relaxed),
640            control_reads: self.control_reads.load(Ordering::Relaxed),
641            control_refused: self.control_refused.load(Ordering::Relaxed),
642            control_sources_shared: self.control_sources_shared.load(Ordering::Relaxed),
643            ignore_patterns_tested: self.ignore_patterns_tested.load(Ordering::Relaxed),
644            ignore_bucket_probes: self.ignore_bucket_probes.load(Ordering::Relaxed),
645            ignore_bucket_hits: self.ignore_bucket_hits.load(Ordering::Relaxed),
646            entries_allocated: self.entries_allocated.load(Ordering::Relaxed),
647            baseline_batches: self.baseline_batches.load(Ordering::Relaxed),
648            baseline_accepted_ops: self.baseline_accepted_ops.load(Ordering::Relaxed),
649            opened_batches: self.opened_batches.load(Ordering::Relaxed),
650            opened_accepted_ops: self.opened_accepted_ops.load(Ordering::Relaxed),
651            public_batches: self.public_batches.load(Ordering::Relaxed),
652            public_accepted_ops: self.public_accepted_ops.load(Ordering::Relaxed),
653            ancestry_overlay_inserts: self.ancestry_overlay_inserts.load(Ordering::Relaxed),
654            ancestry_path_comparisons: self.ancestry_path_comparisons.load(Ordering::Relaxed),
655            ancestry_parent_proofs: self.ancestry_parent_proofs.load(Ordering::Relaxed),
656            scanner_prepare_us: self.scanner_prepare_us.load(Ordering::Relaxed),
657            scanner_control_projection_us: self
658                .scanner_control_projection_us
659                .load(Ordering::Relaxed),
660            scanner_reduce_us: self.scanner_reduce_us.load(Ordering::Relaxed),
661            content_sidecar_read_us: self.content_sidecar_read_us.load(Ordering::Relaxed),
662            content_sidecar_parse_us: self.content_sidecar_parse_us.load(Ordering::Relaxed),
663            content_sidecar_candidates_us: self
664                .content_sidecar_candidates_us
665                .load(Ordering::Relaxed),
666            content_sidecar_apply_us: self.content_sidecar_apply_us.load(Ordering::Relaxed),
667            detached_builds: self.detached_builds.load(Ordering::Relaxed),
668            detached_entries: self.detached_entries.load(Ordering::Relaxed),
669            detached_walk_us: self.detached_walk_us.load(Ordering::Relaxed),
670            detached_finish_us: self.detached_finish_us.load(Ordering::Relaxed),
671            effect_paths: self.effect_paths.load(Ordering::Relaxed),
672            effect_path_bytes: self.effect_path_bytes.load(Ordering::Relaxed),
673            impact_candidates: self.impact_candidates.load(Ordering::Relaxed),
674            impact_ancestor_visits: self.impact_ancestor_visits.load(Ordering::Relaxed),
675            impact_retained_dirty_paths: self.impact_retained_dirty_paths.load(Ordering::Relaxed),
676            impact_all_dirty: self.impact_all_dirty.load(Ordering::Relaxed),
677            journal_retained_commits: self.journal_retained_commits.load(Ordering::Relaxed),
678            journal_cloned_commits: self.journal_cloned_commits.load(Ordering::Relaxed),
679            journal_oversized_commits: self.journal_oversized_commits.load(Ordering::Relaxed),
680            journal_dropped_commits: self.journal_dropped_commits.load(Ordering::Relaxed),
681            adaptive_calibration_chunks: self.adaptive_calibration_chunks.load(Ordering::Relaxed),
682            adaptive_calibration_entries: self.adaptive_calibration_entries.load(Ordering::Relaxed),
683            adaptive_calibration_work_us: self.adaptive_calibration_work_us.load(Ordering::Relaxed),
684            adaptive_scale_ups: self.adaptive_scale_ups.load(Ordering::Relaxed),
685            adaptive_policy_undecided: self.adaptive_policy_undecided.load(Ordering::Relaxed),
686        }
687    }
688
689    fn reset(&self) {
690        self.dir_opens.store(0, Ordering::Relaxed);
691        self.dir_enumeration_calls.store(0, Ordering::Relaxed);
692        self.dir_entries.store(0, Ordering::Relaxed);
693        self.stats.store(0, Ordering::Relaxed);
694        self.file_opens.store(0, Ordering::Relaxed);
695        self.file_reads.store(0, Ordering::Relaxed);
696        self.bytes_read.store(0, Ordering::Relaxed);
697        self.allocs.store(0, Ordering::Relaxed);
698        self.reallocs.store(0, Ordering::Relaxed);
699        self.frees.store(0, Ordering::Relaxed);
700        self.bytes_allocated.store(0, Ordering::Relaxed);
701        self.upserts.store(0, Ordering::Relaxed);
702        self.parent_resolutions.store(0, Ordering::Relaxed);
703        self.parent_memo_hits.store(0, Ordering::Relaxed);
704        self.rollup_merges.store(0, Ordering::Relaxed);
705        self.control_reads.store(0, Ordering::Relaxed);
706        self.control_refused.store(0, Ordering::Relaxed);
707        self.control_sources_shared.store(0, Ordering::Relaxed);
708        self.ignore_patterns_tested.store(0, Ordering::Relaxed);
709        self.ignore_bucket_probes.store(0, Ordering::Relaxed);
710        self.ignore_bucket_hits.store(0, Ordering::Relaxed);
711        self.entries_allocated.store(0, Ordering::Relaxed);
712        self.baseline_batches.store(0, Ordering::Relaxed);
713        self.baseline_accepted_ops.store(0, Ordering::Relaxed);
714        self.opened_batches.store(0, Ordering::Relaxed);
715        self.opened_accepted_ops.store(0, Ordering::Relaxed);
716        self.public_batches.store(0, Ordering::Relaxed);
717        self.public_accepted_ops.store(0, Ordering::Relaxed);
718        self.ancestry_overlay_inserts.store(0, Ordering::Relaxed);
719        self.ancestry_path_comparisons.store(0, Ordering::Relaxed);
720        self.ancestry_parent_proofs.store(0, Ordering::Relaxed);
721        self.scanner_prepare_us.store(0, Ordering::Relaxed);
722        self.scanner_control_projection_us.store(0, Ordering::Relaxed);
723        self.scanner_reduce_us.store(0, Ordering::Relaxed);
724        self.content_sidecar_read_us.store(0, Ordering::Relaxed);
725        self.content_sidecar_parse_us.store(0, Ordering::Relaxed);
726        self.content_sidecar_candidates_us.store(0, Ordering::Relaxed);
727        self.content_sidecar_apply_us.store(0, Ordering::Relaxed);
728        self.detached_builds.store(0, Ordering::Relaxed);
729        self.detached_entries.store(0, Ordering::Relaxed);
730        self.detached_walk_us.store(0, Ordering::Relaxed);
731        self.detached_finish_us.store(0, Ordering::Relaxed);
732        self.effect_paths.store(0, Ordering::Relaxed);
733        self.effect_path_bytes.store(0, Ordering::Relaxed);
734        self.impact_candidates.store(0, Ordering::Relaxed);
735        self.impact_ancestor_visits.store(0, Ordering::Relaxed);
736        self.impact_retained_dirty_paths.store(0, Ordering::Relaxed);
737        self.impact_all_dirty.store(0, Ordering::Relaxed);
738        self.journal_retained_commits.store(0, Ordering::Relaxed);
739        self.journal_cloned_commits.store(0, Ordering::Relaxed);
740        self.journal_oversized_commits.store(0, Ordering::Relaxed);
741        self.journal_dropped_commits.store(0, Ordering::Relaxed);
742        self.adaptive_calibration_chunks.store(0, Ordering::Relaxed);
743        self.adaptive_calibration_entries.store(0, Ordering::Relaxed);
744        self.adaptive_calibration_work_us.store(0, Ordering::Relaxed);
745        self.adaptive_scale_ups.store(0, Ordering::Relaxed);
746        self.adaptive_policy_undecided.store(0, Ordering::Relaxed);
747    }
748}
749
750static GLOBAL: GlobalCounts = GlobalCounts::new();
751
752struct LocalCounts {
753    counts: Cell<Counts>,
754}
755
756impl LocalCounts {
757    const fn new() -> Self {
758        Self { counts: Cell::new(Counts::ZERO) }
759    }
760}
761
762impl Drop for LocalCounts {
763    fn drop(&mut self) {
764        GLOBAL.add(&self.counts.replace(Counts::default()));
765    }
766}
767
768std::thread_local! {
769    // This non-dropping, const-initialized guard is accessed before `LOCAL`. If first
770    // access to a dropping TLS key causes an allocation on a platform, the allocator's
771    // nested callback sees `true` and takes the atomic fallback instead of recursing.
772    static IN_COUNTER: Cell<bool> = const { Cell::new(false) };
773    static LOCAL: LocalCounts = const { LocalCounts::new() };
774}
775
776struct ReentryGuard<'a>(&'a Cell<bool>);
777
778impl<'a> ReentryGuard<'a> {
779    fn enter(cell: &'a Cell<bool>) -> Option<Self> {
780        if cell.replace(true) { None } else { Some(Self(cell)) }
781    }
782}
783
784impl Drop for ReentryGuard<'_> {
785    fn drop(&mut self) {
786        self.0.set(false);
787    }
788}
789
790/// A guard that deterministically folds one worker's counters before it returns.
791pub(crate) struct ThreadFlushGuard;
792
793impl Drop for ThreadFlushGuard {
794    fn drop(&mut self) {
795        flush_thread();
796    }
797}
798
799/// Begin a worker lifetime whose thread-local counters must be visible after join.
800pub(crate) const fn thread_flush_guard() -> ThreadFlushGuard {
801    ThreadFlushGuard
802}
803
804/// One opt-in instrumentation interval for a binary invocation.
805///
806/// Construct this before the measured operation and call [`Self::finish`] afterward.
807/// When recording is off, construction performs no process sampling and `finish`
808/// returns `None`.
809#[must_use]
810pub struct Measurement {
811    process_before: Option<process::Snapshot>,
812}
813
814impl Measurement {
815    /// Read `FDU_COUNTERS` and begin an enabled interval when it is truthy.
816    pub fn from_env() -> Self {
817        if !enable_from_env() {
818            return Self { process_before: None };
819        }
820
821        // The boundary sample can allocate on Linux while parsing `/proc`. Take it first
822        // and reset afterward so the application tier describes fdu's work, not its
823        // measuring instrument.
824        let process_before = process::Snapshot::now();
825        reset();
826        Self { process_before: Some(process_before) }
827    }
828
829    /// Finish the interval and render application and supported process counters.
830    #[must_use]
831    pub fn finish(mut self) -> Option<String> {
832        let before = self.process_before.take()?;
833        flush_thread();
834        let counts = snapshot();
835        let process = process::Snapshot::now().since(&before);
836        enable(false);
837        Some(render(&counts, &process))
838    }
839}
840
841impl Drop for Measurement {
842    fn drop(&mut self) {
843        if self.process_before.is_some() {
844            enable(false);
845        }
846    }
847}
848
849/// Turn recording on or off for the whole process.
850pub fn enable(on: bool) {
851    ENABLED.store(on, Ordering::Relaxed);
852}
853
854/// Turn recording on when `FDU_COUNTERS` contains a truthy value.
855///
856/// Empty strings, `0`, `false`, `no`, and `off` (case-insensitive) disable recording.
857/// The parsed state is always applied, including a transition from enabled to disabled.
858pub fn enable_from_env() -> bool {
859    enable_from_value(std::env::var_os("FDU_COUNTERS").as_deref())
860}
861
862fn enable_from_value(value: Option<&OsStr>) -> bool {
863    let on = value.is_some_and(|value| {
864        value.to_str().is_none_or(|text| {
865            let text = text.trim();
866            !(text.is_empty()
867                || text == "0"
868                || text.eq_ignore_ascii_case("false")
869                || text.eq_ignore_ascii_case("no")
870                || text.eq_ignore_ascii_case("off"))
871        })
872    });
873    enable(on);
874    on
875}
876
877/// Whether recording is currently on.
878#[inline]
879#[must_use]
880pub fn enabled() -> bool {
881    ENABLED.load(Ordering::Relaxed)
882}
883
884/// Elapsed microseconds since `started`, saturating at `u64::MAX`.
885#[must_use]
886pub(crate) fn elapsed_micros(started: std::time::Instant) -> u64 {
887    u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX)
888}
889
890/// Record a whole-phase duration when recording is on.
891pub(crate) fn add_elapsed(started: Option<std::time::Instant>, add: impl FnOnce(&mut Counts, u64)) {
892    if let Some(started) = started {
893        bump(|counts| add(counts, elapsed_micros(started)));
894    }
895}
896
897/// Add to one or more counters on the calling thread.
898///
899/// The fallback path is important for allocation callbacks: it avoids re-entering a TLS
900/// initializer and remains usable while a thread's local counter has begun destruction.
901#[inline]
902pub fn bump(f: impl FnOnce(&mut Counts)) {
903    if !enabled() {
904        return;
905    }
906
907    let mut f = Some(f);
908    let stored_locally = IN_COUNTER
909        .try_with(|reentry| {
910            let Some(_guard) = ReentryGuard::enter(reentry) else { return false };
911            LOCAL
912                .try_with(|local| {
913                    if let Some(update) = f.take() {
914                        let mut counts = local.counts.get();
915                        update(&mut counts);
916                        local.counts.set(counts);
917                    }
918                })
919                .is_ok()
920        })
921        .unwrap_or(false);
922
923    if !stored_locally {
924        let mut delta = Counts::default();
925        if let Some(update) = f {
926            update(&mut delta);
927            GLOBAL.add(&delta);
928        }
929    }
930}
931
932/// Fold this thread's counts into the process total and clear them.
933pub fn flush_thread() {
934    let _ = LOCAL.try_with(|local| GLOBAL.add(&local.counts.replace(Counts::default())));
935}
936
937/// Process-wide totals, including the calling thread's unflushed counts.
938#[must_use]
939pub fn snapshot() -> Counts {
940    let mut total = GLOBAL.snapshot();
941    let _ = LOCAL.try_with(|local| total.add(&local.counts.get()));
942    total
943}
944
945/// The calling thread's unflushed counts, without consuming them.
946///
947/// Use paired reads to attribute synchronous diagnostic work without also counting
948/// workers that finish during it. A [`flush_thread`] between reads starts a new local
949/// interval; these are not lifetime totals. No other thread's counts are included.
950#[must_use]
951pub fn thread_snapshot() -> Counts {
952    LOCAL.try_with(|local| local.counts.get()).unwrap_or_default()
953}
954
955/// Clear process totals and the calling thread's local counts.
956///
957/// Call only when other instrumented workers are quiescent: another thread may still
958/// hold local counts that a process-global reset cannot reach.
959pub fn reset() {
960    let _ = LOCAL.try_with(|local| local.counts.set(Counts::default()));
961    GLOBAL.reset();
962}
963
964/// Create fdu's certified system-allocator wrapper for a binary's global allocator.
965#[must_use]
966pub const fn system_allocator() -> alloc::CountingAlloc<std::alloc::System> {
967    alloc::CountingAlloc::system(alloc::fdu_sinks())
968}
969
970/// Render application counters followed by the supported kernel process counters.
971#[must_use]
972pub fn render(counts: &Counts, process: &process::Snapshot) -> String {
973    let mut out = render_rows(&counts.rows());
974    out.push('\n');
975    out.push_str(&process.render());
976    out
977}
978
979fn record_alloc(size: u64) {
980    bump(|counts| {
981        counts.allocs = counts.allocs.saturating_add(1);
982        counts.bytes_allocated = counts.bytes_allocated.saturating_add(size);
983    });
984}
985
986fn record_realloc(growth: u64) {
987    bump(|counts| {
988        counts.reallocs = counts.reallocs.saturating_add(1);
989        counts.bytes_allocated = counts.bytes_allocated.saturating_add(growth);
990    });
991}
992
993fn record_dealloc() {
994    bump(|counts| counts.frees = counts.frees.saturating_add(1));
995}
996
997fn atomic_saturating_add(target: &AtomicU64, value: u64) {
998    if value == 0 {
999        return;
1000    }
1001    let _ = target.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
1002        Some(current.saturating_add(value))
1003    });
1004}
1005
1006#[allow(clippy::cast_precision_loss)]
1007fn ratio(numerator: u64, denominator: u64) -> f64 {
1008    let denominator = if denominator == 0 { 1.0 } else { denominator as f64 };
1009    numerator as f64 / denominator
1010}
1011
1012fn render_rows(rows: &[(&str, &str, u64)]) -> String {
1013    use std::fmt::Write as _;
1014
1015    let width = rows.iter().map(|(_, label, _)| label.len()).max().unwrap_or(0);
1016    let mut output = String::new();
1017    let mut previous = None;
1018    for (group, label, value) in rows {
1019        if previous != Some(*group) {
1020            if previous.is_some() {
1021                output.push('\n');
1022            }
1023            let _ = writeln!(output, "[{group}]");
1024            previous = Some(*group);
1025        }
1026        let _ = writeln!(output, "  {label:<width$}  {value:>13}");
1027    }
1028    output
1029}
1030
1031/// Serialize tests that touch process-global counter state.
1032#[cfg(test)]
1033pub(crate) fn test_serial() -> std::sync::MutexGuard<'static, ()> {
1034    static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
1035    SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
1036}
1037
1038/// Calling-thread counts for exact unit tests that must ignore unrelated workers.
1039#[cfg(test)]
1040pub(crate) fn test_thread_snapshot() -> Counts {
1041    thread_snapshot()
1042}
1043
1044/// Clear only the calling test thread's counts.
1045#[cfg(test)]
1046pub(crate) fn test_thread_reset() {
1047    let _ = LOCAL.try_with(|local| local.counts.set(Counts::default()));
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    #[test]
1055    fn an_exiting_thread_folds_without_a_manual_flush() {
1056        let _serial = test_serial();
1057        enable(true);
1058        reset();
1059
1060        std::thread::spawn(|| bump(|counts| counts.dir_opens = 7)).join().expect("counting worker");
1061
1062        // Other ordinary tests can perform instrumented work while this process-wide
1063        // toggle is on, so only the worker's lower bound is deterministic here.
1064        assert!(snapshot().dir_opens >= 7);
1065        reset();
1066        enable(false);
1067    }
1068
1069    #[test]
1070    fn thread_snapshot_excludes_workers_without_clearing_local_counts() {
1071        let _serial = test_serial();
1072        enable(true);
1073        reset();
1074        bump(|counts| counts.dir_opens = 3);
1075
1076        std::thread::spawn(|| bump(|counts| counts.dir_opens = 7)).join().expect("worker");
1077
1078        assert_eq!(thread_snapshot().dir_opens, 3);
1079        assert_eq!(thread_snapshot().dir_opens, 3, "reading must not consume counts");
1080        assert!(snapshot().dir_opens >= 10);
1081        flush_thread();
1082        assert_eq!(thread_snapshot().dir_opens, 0);
1083        reset();
1084        enable(false);
1085    }
1086
1087    #[test]
1088    fn a_falsey_value_disables_a_previously_enabled_process() {
1089        let _serial = test_serial();
1090        enable(true);
1091
1092        assert!(!enable_from_value(Some(OsStr::new("FALSE"))));
1093        assert!(!enabled());
1094    }
1095
1096    #[test]
1097    fn ratios_treat_a_zero_denominator_as_one() {
1098        let counts = Counts { allocs: 10, ..Counts::default() };
1099        let allocations = counts
1100            .per(0)
1101            .into_iter()
1102            .find(|(_, label, _)| *label == "allocations")
1103            .expect("allocation row");
1104        assert!((allocations.2 - 10.0).abs() < f64::EPSILON);
1105    }
1106}