captrack 0.1.0

Capacity telemetry for Rust collections — call-site macros that record peak capacity, with zero overhead when disabled.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
// Auto-dump capacity statistics during and after process execution.
//
// Compiled only with the `telemetry` feature.  Two cooperating hooks ensure
// the global registry survives whatever termination strategy the host
// uses (graceful exit, hard kill, `timeout`-induced `TerminateProcess`):
//
//   1. **Periodic background thread** (registered via `#[ctor::ctor]`)
//      writes a fresh snapshot every `CAPTRACK_AUTODUMP_INTERVAL_MS`
//      milliseconds (default 2 000 ms).  The most recent snapshot survives
//      even when the process is killed with `SIGKILL` / Windows
//      `TerminateProcess` — both bypass atexit handlers but the on-disk
//      file from the previous tick is intact.
//
//   2. **Atexit destructor** (registered via `#[ctor::dtor]`) writes the
//      final snapshot when the process exits normally — this catches data
//      that accumulated in the last sub-interval window before exit.
//
// Criterion-driven benches never expose their `main` directly, so this is
// the only practical way to capture their data without source patches.
//
// # Output path
//
// `<dir>/profile-<binary_stem>-<pid>-<start_ms>.json` where:
//
// * `<dir>` is read from the `CAPTRACK_DUMP_DIR` environment variable, falling
//   back to `target/captrack-pgo` if unset.
// * `<binary_stem>` is the file stem of `std::env::current_exe()`, falling back
//   to `"unknown"` if the platform cannot report it.
// * `<pid>` is this process's OS process id.
// * `<start_ms>` is the wall-clock time (ms since UNIX epoch) of the first
//   `default_dump_path()` call in this process, cached in a `OnceLock`.
//
// The PID matters: `current_exe()`'s stem is IDENTICAL across every OS
// process that runs the same compiled test/bench binary, and test harnesses
// that launch one fresh process per test (`cargo nextest run`'s default
// mode) routinely spawn dozens of such processes for one binary. Without a
// per-process-unique destination, every process's periodic-tick / atexit
// write targets the exact same file — the last process to finish "wins" and
// silently discards every earlier process's accumulated samples, even
// though nothing crashed or corrupted (the write itself is atomic; the data
// loss is at the "which snapshot is the file" level, across processes, not
// within one). Concretely: crate with 50 tests run as 50 nextest processes,
// 49 of which construct many tracked collections and one trivial one that
// constructs none — if that trivial one happens to exit last, the dump ends
// up `{"stats": []}` and the other 49 processes' data is gone. Each process
// now gets its own destination file; `captrack-pgo merge`'s glob input
// already exists specifically to recombine multiple profile files, so nCPU
// processes of one binary naturally becomes nCPU dump files that merge
// picks up together instead of one clobbering the rest.
//
// The `<start_ms>` component exists because the PID alone is NOT unique
// across the *lifetime of one profiling run*: operating systems recycle PIDs
// of exited processes aggressively (observed on Windows: a ~4000-test
// nextest run produced only ~3350 dump files — ~16% of processes drew a PID
// already used by an earlier, finished process of the same binary and
// overwrote its dump). Two *live* processes never share a PID, and a
// recycled PID cannot re-appear within the same millisecond its predecessor
// started, so `(stem, pid, start_ms)` is collision-free in practice. The
// value is cached in a `OnceLock` so the periodic ticker (which captures the
// path at process start) and the atexit destructor (which recomputes it at
// exit) agree on one destination — otherwise a single process would split
// its snapshots across two files and `merge` would double-count it.
//
// Consequence: dump files accumulate across profiling runs instead of being
// overwritten. Point `CAPTRACK_DUMP_DIR` at a fresh directory (or clear it)
// between runs so `captrack-pgo merge`'s glob doesn't pick up stale data.
//
// The directory is created on demand (`dump_capacity_stats` does this already).
//
// # Configuration
//
// * `CAPTRACK_AUTODUMP=0|off|false|no` — disable both hooks entirely.
// * `CAPTRACK_AUTODUMP_INTERVAL_MS=<u64>` — periodic-thread interval
//   (default 2 000).  Set to `0` to disable the background thread (only the
//   atexit destructor runs).
// * `CAPTRACK_DUMP_DIR=<path>` — override output directory.
//
// Manual `dump_capacity_stats(path)` calls always work regardless of the
// environment.

use std::path::PathBuf;
use std::time::Duration;

/// Wall-clock time of the first call, in ms since UNIX epoch, cached for the
/// process lifetime.  Disambiguates dump filenames when the OS recycles the
/// PID of an already-exited process of the same binary (see module docs).
/// Cached (rather than sampled per call) so the ticker thread and the atexit
/// destructor produce the SAME path — a per-call timestamp would split one
/// process's snapshots across two files and `merge` would double-count them.
fn process_start_millis() -> u128 {
    static START_MS: std::sync::OnceLock<u128> = std::sync::OnceLock::new();
    *START_MS.get_or_init(|| {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_millis())
            .unwrap_or(0)
    })
}

/// Compute the auto-dump output path:
/// `<CAPTRACK_DUMP_DIR>/profile-<binary_stem>-<pid>-<start_ms>.json`.
///
/// Only the `<start_ms>` component is cached; `<dir>`/`<stem>` are re-read
/// per call (cheap, and keeps `CAPTRACK_DUMP_DIR` testable).
fn default_dump_path() -> PathBuf {
    let dir = std::env::var_os("CAPTRACK_DUMP_DIR")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("target/captrack-pgo"));
    let stem = std::env::current_exe()
        .ok()
        .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().into_owned()))
        .unwrap_or_else(|| "unknown".to_string());
    dir.join(format!(
        "profile-{stem}-{}-{}.json",
        std::process::id(),
        process_start_millis()
    ))
}

/// Returns true unless the user explicitly disabled auto-dump via
/// `CAPTRACK_AUTODUMP=0|off|false|no`.
fn autodump_enabled() -> bool {
    match std::env::var("CAPTRACK_AUTODUMP") {
        Ok(v) => !matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "0" | "off" | "false" | "no"
        ),
        Err(_) => true,
    }
}

/// Periodic-dump interval in milliseconds.  `0` → background thread is not
/// spawned; the atexit destructor is the only writer.  Default: 500 ms — a
/// compromise between I/O overhead and how much data can be lost when the
/// host process is killed mid-tick.  At 500 ms the worst-case loss window
/// is half a second on POSIX `SIGKILL` / Windows `TerminateProcess`.
fn autodump_interval_ms() -> u64 {
    std::env::var("CAPTRACK_AUTODUMP_INTERVAL_MS")
        .ok()
        .and_then(|v| v.trim().parse::<u64>().ok())
        .unwrap_or(500)
}

/// Destructor wired via `ctor::dtor` — runs after `main` returns (or after
/// the last user thread terminates).  Errors are deliberately swallowed:
/// failing to dump must not crash the host process during shutdown.
#[ctor::dtor]
fn captrack_autodump_on_exit() {
    if !autodump_enabled() {
        return;
    }
    let path = default_dump_path();
    let _ = crate::dump::dump_capacity_stats(&path);
}

/// Spawn the periodic-dump background thread at process start.  Wired via
/// `#[ctor::ctor]` so it begins ticking before `main` is entered — the
/// thread sleeps `interval_ms` between writes, so the first snapshot
/// appears `interval_ms` after process start (long enough that the registry
/// has typically seen its first allocations).
///
/// The thread is `JoinHandle`-less and runs detached for the entire process
/// lifetime; the runtime's normal teardown reaps it.  We never `.join()` it
/// because a stuck I/O write should not block process exit.
#[ctor::ctor]
fn captrack_autodump_spawn_ticker() {
    if !autodump_enabled() {
        return;
    }
    let interval_ms = autodump_interval_ms();
    if interval_ms == 0 {
        return;
    }
    let path = default_dump_path();
    std::thread::Builder::new()
        .name("captrack-autodump".into())
        .spawn(move || {
            let interval = Duration::from_millis(interval_ms);
            loop {
                std::thread::sleep(interval);
                // Errors deliberately swallowed: a transient write failure
                // (e.g. disk full, antivirus lock) must not crash the host.
                // The next tick re-attempts.
                let _ = crate::dump::dump_capacity_stats(&path);
            }
        })
        // If the OS refuses to spawn (unrealistic in practice), the periodic
        // path silently degrades to atexit-only — the dtor still fires on
        // normal exit.
        .ok();
}

// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    // Serializes tests that mutate process-wide environment variables —
    // `cargo test` runs tests in the same binary concurrently by default, and
    // std::env mutation is process-global.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    // ── autodump_interval_ms ─────────────────────────────────────────────────

    #[test]
    fn interval_defaults_to_500ms_when_unset() {
        let _guard = ENV_LOCK.lock().unwrap();
        let prev = std::env::var_os("CAPTRACK_AUTODUMP_INTERVAL_MS");
        std::env::remove_var("CAPTRACK_AUTODUMP_INTERVAL_MS");

        assert_eq!(autodump_interval_ms(), 500);

        if let Some(v) = prev {
            std::env::set_var("CAPTRACK_AUTODUMP_INTERVAL_MS", v);
        }
    }

    #[test]
    fn interval_respects_env_override() {
        let _guard = ENV_LOCK.lock().unwrap();
        let prev = std::env::var_os("CAPTRACK_AUTODUMP_INTERVAL_MS");
        std::env::set_var("CAPTRACK_AUTODUMP_INTERVAL_MS", "123");

        assert_eq!(autodump_interval_ms(), 123);

        match prev {
            Some(v) => std::env::set_var("CAPTRACK_AUTODUMP_INTERVAL_MS", v),
            None => std::env::remove_var("CAPTRACK_AUTODUMP_INTERVAL_MS"),
        }
    }

    #[test]
    fn interval_falls_back_to_default_on_unparseable_value() {
        let _guard = ENV_LOCK.lock().unwrap();
        let prev = std::env::var_os("CAPTRACK_AUTODUMP_INTERVAL_MS");
        std::env::set_var("CAPTRACK_AUTODUMP_INTERVAL_MS", "not-a-number");

        assert_eq!(autodump_interval_ms(), 500);

        match prev {
            Some(v) => std::env::set_var("CAPTRACK_AUTODUMP_INTERVAL_MS", v),
            None => std::env::remove_var("CAPTRACK_AUTODUMP_INTERVAL_MS"),
        }
    }

    // ── autodump_enabled ─────────────────────────────────────────────────────

    #[test]
    fn enabled_by_default_when_unset() {
        let _guard = ENV_LOCK.lock().unwrap();
        let prev = std::env::var_os("CAPTRACK_AUTODUMP");
        std::env::remove_var("CAPTRACK_AUTODUMP");

        assert!(autodump_enabled());

        if let Some(v) = prev {
            std::env::set_var("CAPTRACK_AUTODUMP", v);
        }
    }

    #[test]
    fn disabled_by_recognized_falsy_values() {
        let _guard = ENV_LOCK.lock().unwrap();
        let prev = std::env::var_os("CAPTRACK_AUTODUMP");

        for falsy in ["0", "off", "false", "no", "OFF", "False"] {
            std::env::set_var("CAPTRACK_AUTODUMP", falsy);
            assert!(!autodump_enabled(), "expected {falsy} to disable autodump");
        }

        match prev {
            Some(v) => std::env::set_var("CAPTRACK_AUTODUMP", v),
            None => std::env::remove_var("CAPTRACK_AUTODUMP"),
        }
    }

    // ── default_dump_path ────────────────────────────────────────────────────

    #[test]
    fn dump_path_respects_captrack_dump_dir_override() {
        let _guard = ENV_LOCK.lock().unwrap();
        let prev = std::env::var_os("CAPTRACK_DUMP_DIR");
        std::env::set_var("CAPTRACK_DUMP_DIR", "some/custom/dir");

        let path = default_dump_path();
        assert!(path.starts_with("some/custom/dir"));
        assert!(path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("profile-"));

        match prev {
            Some(v) => std::env::set_var("CAPTRACK_DUMP_DIR", v),
            None => std::env::remove_var("CAPTRACK_DUMP_DIR"),
        }
    }

    #[test]
    fn dump_path_is_qualified_by_pid_and_start_time() {
        // Take the same lock `dump_path_respects_captrack_dump_dir_override`
        // uses around its `CAPTRACK_DUMP_DIR` mutation. Without this guard,
        // the two `default_dump_path()` calls below race against that other
        // test's set_var/remove_var (Rust's default harness runs tests in
        // parallel threads), so the "dir" component of the path can change
        // between call 1 and call 2 even though `process_start_millis()` is
        // correctly cached — an intermittent failure, not a real regression.
        let _guard = ENV_LOCK.lock().unwrap();

        // Regression test, two layers:
        //
        // 1. PID: cargo-nextest launches one OS process per test by default,
        //    so current_exe()'s stem alone is identical across many
        //    concurrent/sequential processes of the same binary. Without a
        //    PID in the destination filename, whichever process finishes
        //    last wins the shared path and silently discards every earlier
        //    process's accumulated samples.
        //
        // 2. Start time: the OS recycles PIDs of exited processes within one
        //    profiling run (observed ~16% dump loss on a ~4000-process
        //    nextest run), so `(stem, pid)` alone still collides across
        //    non-overlapping process lifetimes. `(stem, pid, start_ms)`
        //    doesn't — see module docs.
        let path = default_dump_path();
        let file_name = path.file_name().unwrap().to_string_lossy().into_owned();
        let expected_suffix = format!("-{}-{}.json", std::process::id(), process_start_millis());
        assert!(
            file_name.ends_with(&expected_suffix),
            "dump path must end with -<pid>-<start_ms>.json, got: {file_name}"
        );

        // The path must be STABLE within one process: the ticker captures it
        // at process start, the atexit dtor recomputes it at exit. If the
        // timestamp were sampled per call they would diverge, splitting one
        // process's data across two files (double-counted by merge).
        assert_eq!(
            path,
            default_dump_path(),
            "default_dump_path must return the same path on every call"
        );
    }

    // ── end-to-end: dump format + atomic write (via dump::dump_capacity_stats,
    //    the same function the periodic ticker and the atexit dtor call) ─────

    #[test]
    fn dump_output_is_atomic_and_leaves_no_tmp_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("profile-test.json");

        crate::dump::dump_capacity_stats(&path).unwrap();

        assert!(
            path.is_file(),
            "dump file must exist after a successful write"
        );

        // tmp filename is PID-qualified (`<name>.<pid>.tmp`) — see dump.rs.
        let tmp_path = {
            let mut name = path.file_name().unwrap().to_os_string();
            name.push(format!(".{}.tmp", std::process::id()));
            path.with_file_name(name)
        };
        assert!(
            !tmp_path.exists(),
            "no .tmp file must remain after a successful write"
        );
    }

    #[test]
    fn dump_concurrent_writers_never_corrupt_the_destination() {
        // Regression test: cargo-nextest launches one OS process per test by
        // default, and every process of the *same* compiled test/bench binary
        // computes the identical destination path (current_exe()'s stem).
        // Multiple threads racing dump_capacity_stats against the same path
        // simulates that cross-process collision within one test — before the
        // PID-qualified tmp_path + DUMP_LOCK fix, this reliably produced a
        // destination file with two concatenated JSON documents.
        let dir = tempfile::tempdir().unwrap();
        let path = std::sync::Arc::new(dir.path().join("profile-concurrent.json"));

        let handles: Vec<_> = (0..8)
            .map(|_| {
                let path = std::sync::Arc::clone(&path);
                std::thread::spawn(move || {
                    for _ in 0..20 {
                        crate::dump::dump_capacity_stats(path.as_path()).unwrap();
                    }
                })
            })
            .collect();
        for h in handles {
            h.join().unwrap();
        }

        let text = std::fs::read_to_string(path.as_path()).unwrap();
        let parsed: Result<serde_json::Value, _> = serde_json::from_str(&text);
        assert!(
            parsed.is_ok(),
            "destination must always be exactly one valid JSON document, got parse error on: {text}"
        );
    }

    #[test]
    fn dump_output_contains_total_observed_field() {
        use crate::tvec;

        // Force at least one registry entry so the dump has content.
        let v: crate::TrackedVec<i32> = tvec!("autodump-test-total-observed", 4);
        drop(v);

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("profile-total-observed.json");
        crate::dump::dump_capacity_stats(&path).unwrap();

        let text = std::fs::read_to_string(&path).unwrap();
        assert!(
            text.contains("total_observed"),
            "dump JSON must include total_observed; got: {text}"
        );
    }

    #[test]
    fn dump_does_not_fail_on_empty_registry() {
        // A path under a fresh tempdir guarantees no other test has written
        // to the same file; the registry itself may or may not be empty
        // depending on test execution order, but the call must succeed and
        // produce valid JSON regardless.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("profile-empty.json");

        let result = crate::dump::dump_capacity_stats(&path);
        assert!(result.is_ok());
        assert!(path.is_file());

        let text = std::fs::read_to_string(&path).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert!(parsed.get("stats").is_some());
    }
}