alloc_tracker 0.7.0

Memory allocation tracking utilities for benchmarks and performance analysis
Documentation
//! Integration tests for `alloc_tracker` with real memory allocations.
//!
//! These tests use a global allocator setup to test the full functionality
//! of the allocation tracking system, including single-threaded and multithreaded scenarios.

use std::hint::black_box;
use std::thread;

use alloc_tracker::{Allocator, Session};

#[global_allocator]
static ALLOCATOR: Allocator<std::alloc::System> = Allocator::system();

/// Reads an operation's total bytes allocated the way a user inspects results:
/// through the session report rather than the live `Operation` handle.
fn report_total_bytes(session: &Session, operation_name: &str) -> u64 {
    session
        .to_report()
        .operations()
        .find(|&(name, _)| name == operation_name)
        .map_or_else(
            || panic!("operation {operation_name:?} should appear in the report"),
            |(_, op)| op.total_bytes_allocated(),
        )
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn no_span_is_empty_session() {
    let session = Session::new().no_stdout().no_file();

    let _op = session.operation("test_no_span");

    assert!(session.is_empty());
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn span_with_no_allocation_is_not_empty_session() {
    let session = Session::new().no_stdout().no_file();

    {
        let op = session.operation("test_no_allocation");
        drop(op.measure_process().iterations(1));
    } // op is dropped here, merging data to session

    assert!(
        !session.is_empty(),
        "Session should not be empty after creating a span"
    );
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn single_thread_allocations() {
    const BYTES_PER_ITERATION: usize = 100;
    const TEST_ITERATIONS: usize = 5;

    let session = Session::new().no_stdout().no_file();

    // Test process span in single-threaded context
    {
        let process_op = session.operation("process_single_thread");
        for i in 1..=TEST_ITERATIONS {
            let _span = process_op.measure_process().iterations(1);
            let _data = vec![0_u8; i * BYTES_PER_ITERATION];
            black_box(&_data);
        }
    }
    let process_total = report_total_bytes(&session, "process_single_thread");

    // Test thread span in single-threaded context
    {
        let thread_op = session.operation("thread_single_thread");
        for i in 1..=TEST_ITERATIONS {
            let _span = thread_op.measure_thread().iterations(1);
            let _data = vec![0_u8; i * BYTES_PER_ITERATION];
            black_box(&_data);
        }
    }
    let thread_total = report_total_bytes(&session, "thread_single_thread");

    // Both should have allocated some memory
    assert!(process_total > 0);
    assert!(thread_total > 0);

    assert!(process_total >= thread_total);
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn multithreaded_allocations_show_span_differences() {
    const NUM_WORKER_THREADS: u32 = 4;
    const ALLOCATIONS_PER_THREAD: u32 = 50;
    const MAIN_THREAD_ALLOCATIONS: u32 = 10;
    const TEST_ITERATIONS: usize = 3;

    let session = Session::new().no_stdout().no_file();

    // Helper function to spawn worker threads that allocate memory
    let spawn_workers = || {
        let handles: Vec<_> = (0..NUM_WORKER_THREADS)
            .map(|thread_id| {
                thread::spawn(move || {
                    for i in 0..ALLOCATIONS_PER_THREAD {
                        let size = ((thread_id + 1) * 100 + i) as usize;
                        let data = vec![42_u8; size];
                        black_box(data);
                    }
                })
            })
            .collect();

        // Do some allocations on the main thread
        for i in 0..MAIN_THREAD_ALLOCATIONS {
            #[expect(
                clippy::cast_possible_truncation,
                reason = "small test values will not truncate"
            )]
            let data = vec![i as u8; 100];
            black_box(data);
        }

        // Wait for all threads to complete
        for handle in handles {
            handle.join().unwrap();
        }
    };

    // Test process span with multithreaded work (should capture all threads)
    {
        let process_op = session.operation("process_multithreaded");
        for _ in 0..TEST_ITERATIONS {
            let _span = process_op.measure_process().iterations(1);
            spawn_workers();
        }
    }
    let process_total = report_total_bytes(&session, "process_multithreaded");

    // Test thread span with multithreaded work (should only capture main thread)
    {
        let thread_op = session.operation("thread_multithreaded");
        for _ in 0..TEST_ITERATIONS {
            let _span = thread_op.measure_thread().iterations(1);
            spawn_workers();
        }
    }
    let thread_total = report_total_bytes(&session, "thread_multithreaded");

    // Both should have allocated some memory
    assert!(process_total > 0);
    assert!(thread_total > 0);

    // Process span should capture significantly more than thread span
    assert!(
        process_total > thread_total * 2,
        "Process span should capture much more allocation than thread span in multithreaded context. Process: {process_total}, Thread: {thread_total}"
    );
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn mixed_span_types_in_multithreaded_context() {
    const ITERATIONS: usize = 3;

    let session = Session::new().no_stdout().no_file();
    let mixed_op = session.operation("mixed_multithreaded");

    for iteration in 1..=ITERATIONS {
        // Alternate between process and thread spans
        if iteration % 2 == 0 {
            let _span = mixed_op.measure_process().iterations(1);
            // Spawn a thread that allocates memory
            let handle = thread::spawn(|| {
                let data = vec![0_u8; 500];
                black_box(data);
            });
            // Also allocate on main thread
            let data = vec![0_u8; 100];
            black_box(data);
            handle.join().unwrap();
        } else {
            let _span = mixed_op.measure_thread().iterations(1);
            // Spawn a thread that allocates memory (will not be captured by thread span)
            let handle = thread::spawn(|| {
                let data = vec![0_u8; 500];
                black_box(data);
            });
            // Only main thread allocation should be captured
            let data = vec![0_u8; 100];
            black_box(data);
            handle.join().unwrap();
        }
    }

    let total = report_total_bytes(&session, "mixed_multithreaded");
    assert!(total > 0);
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn report_is_empty_matches_session_is_empty() {
    let session = Session::new().no_stdout().no_file();

    // Test 1: Both empty initially
    let report = session.to_report();
    assert_eq!(session.is_empty(), report.is_empty());
    assert!(session.is_empty());
    assert!(report.is_empty());

    // Test 2: Create operation without spans - both should still be empty
    let _operation = session.operation("test");
    let report = session.to_report();
    assert_eq!(session.is_empty(), report.is_empty());
    assert!(session.is_empty());
    assert!(report.is_empty());

    // Test 3: Add spans - both should be non-empty
    {
        let operation = session.operation("test_with_spans");
        let _span = operation.measure_process().iterations(1);
        // No actual allocation needed for span to exist
    } // Operation is dropped here, merging data to session

    let report = session.to_report();
    assert_eq!(session.is_empty(), report.is_empty());
    assert!(!session.is_empty());
    assert!(!report.is_empty());
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn report_slope_with_known_allocations() {
    const NUM_ITERATIONS: u64 = 5;

    let session = Session::new().no_stdout().no_file();

    {
        let operation = session.operation("known_allocation");
        for _ in 0..NUM_ITERATIONS {
            let _span = operation.measure_thread().iterations(1);
            // Two distinct heap allocations per iteration - a predictable
            // allocation count, independent of any allocator size overhead.
            let first = Box::new(42_u64);
            let second = Box::new(7_u64);
            black_box((first, second)); // Ensure the allocations are not optimized away.
        }
    } // Operation is dropped here, merging data to session

    let report = session.to_report();
    let operations: Vec<_> = report.operations().collect();
    assert_eq!(operations.len(), 1);

    let (_name, op) = operations.first().unwrap();
    assert_eq!(op.total_iterations(), NUM_ITERATIONS);

    // Check that we have tracked some allocations
    let total_bytes = op.total_bytes_allocated();
    assert!(total_bytes > 0);

    // Two `Box<u64>` values require at least 16 bytes per iteration, though
    // allocators may add overhead, so we just verify basic sanity.
    assert!(total_bytes >= NUM_ITERATIONS * 16);

    // The per-iteration byte figure (the warmup-robust slope) should be positive.
    let bytes_per_iteration = op
        .bytes()
        .expect("operation with recorded spans has an estimable per-iteration slope");
    assert!(bytes_per_iteration > 0.0);

    // The spans are identical single-iteration allocations, so the slope
    // collapses onto total / iterations.
    #[expect(
        clippy::cast_precision_loss,
        reason = "byte counts in this test are far below f64's exact-integer range"
    )]
    let expected = total_bytes as f64 / NUM_ITERATIONS as f64;
    assert!((bytes_per_iteration - expected).abs() < 1.0);

    // It should be at least the 16 bytes two `Box<u64>` values require.
    assert!(bytes_per_iteration >= 16.0);

    // Each iteration performs exactly two allocations, so the allocation-count
    // slope should be two.
    let allocations_per_iteration = op
        .allocations()
        .expect("operation with recorded spans has an estimable per-iteration slope");
    assert!((allocations_per_iteration - 2.0).abs() < f64::EPSILON);
}

#[test]
#[cfg_attr(miri, ignore)] // Test uses the real platform which cannot be executed under Miri.
fn process_report_includes_allocations_from_multiple_threads() {
    const THREAD_A_ALLOCS: usize = 40;
    const THREAD_B_ALLOCS: usize = 25;
    const SIZE_A: usize = 128; // bytes per allocation in thread A
    const SIZE_B: usize = 256; // bytes per allocation in thread B

    let session = Session::new().no_stdout().no_file();
    {
        let op = session.operation("two_thread_process");
        let _span = op.measure_process().iterations(1);

        let handle_a = thread::spawn(|| {
            let mut total = 0_usize;
            for _ in 0..THREAD_A_ALLOCS {
                let v = vec![0_u8; SIZE_A];
                total += v.len();
                black_box(&v);
            }
            total
        });

        let handle_b = thread::spawn(|| {
            let mut total = 0_usize;
            for _ in 0..THREAD_B_ALLOCS {
                let v = vec![1_u8; SIZE_B];
                black_box(&v);
                total += v.len();
            }
            total
        });

        // Also allocate on the main thread so we can distinguish process span > sum of one thread.
        let main_alloc = vec![2_u8; 64];
        black_box(&main_alloc);

        let a_bytes = handle_a.join().unwrap();
        let b_bytes = handle_b.join().unwrap();

        // Basic sanity: ensure we actually performed the expected sizes.
        assert_eq!(a_bytes, THREAD_A_ALLOCS * SIZE_A);
        assert_eq!(b_bytes, THREAD_B_ALLOCS * SIZE_B);
    }

    let report = session.to_report();
    let operations: Vec<_> = report.operations().collect();
    assert_eq!(
        operations.len(),
        1,
        "expected exactly one operation in report"
    );
    let (_name, op) = operations.first().unwrap();
    let total = op.total_bytes_allocated();

    // Expect at least the sum of the two thread totals (plus main thread allocation overhead)
    let min_expected = (THREAD_A_ALLOCS * SIZE_A + THREAD_B_ALLOCS * SIZE_B) as u64;
    assert!(
        total >= min_expected,
        "total {total} < expected minimum {min_expected}"
    );

    // Ensure neither thread's contribution is trivially missing: total should exceed each individual component
    assert!(total >= (THREAD_A_ALLOCS * SIZE_A) as u64);
    assert!(total >= (THREAD_B_ALLOCS * SIZE_B) as u64);
}