Skip to main content

memscope_rs/capture/backends/
core_tracker.rs

1//! Core memory tracker implementation (self-contained, no old system dependencies)
2//!
3//! This module provides high-performance memory tracking using DashMap
4//! and atomic operations, completely independent of the old system.
5
6use super::core_types::{
7    AllocationInfo, MemoryStats, ThreadRegistryStats, TrackingError, TrackingResult,
8};
9use dashmap::DashMap;
10use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
11use std::sync::{Arc, OnceLock};
12use std::thread;
13
14const STRATEGY_GLOBAL_SINGLETON: u64 = 0;
15const STRATEGY_THREAD_LOCAL: u64 = 1;
16
17pub static TRACKING_STRATEGY: AtomicU64 = AtomicU64::new(STRATEGY_GLOBAL_SINGLETON);
18
19static GLOBAL_TRACKER: OnceLock<Arc<MemoryTracker>> = OnceLock::new();
20
21thread_local! {
22    static THREAD_LOCAL_TRACKER: Arc<MemoryTracker> = {
23        let tracker = Arc::new(MemoryTracker::new());
24        register_current_thread_tracker_local(&tracker);
25        tracker
26    };
27}
28
29/// Thread-local registry for tracking thread-local trackers
30static LOCAL_THREAD_REGISTRY: OnceLock<
31    Arc<dashmap::DashMap<thread::ThreadId, Arc<MemoryTracker>>>,
32> = OnceLock::new();
33
34fn get_local_registry() -> Arc<dashmap::DashMap<thread::ThreadId, Arc<MemoryTracker>>> {
35    LOCAL_THREAD_REGISTRY
36        .get_or_init(|| Arc::new(dashmap::DashMap::new()))
37        .clone()
38}
39
40fn register_current_thread_tracker_local(tracker: &Arc<MemoryTracker>) {
41    let thread_id = thread::current().id();
42    get_local_registry().insert(thread_id, tracker.clone());
43}
44
45/// Identity of a binding that was closed (or located) by the cache.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct ClosedBinding {
48    /// Base address the binding was recorded at.
49    pub ptr: usize,
50    /// Identity of the binding (`AllocationInfo::binding_serial`).
51    pub serial: u64,
52    /// Size recorded for the binding.
53    pub size: usize,
54}
55
56/// Core memory tracking functionality.
57///
58/// The MemoryTracker maintains records of all memory allocations and deallocations,
59/// provides statistics, and supports exporting data in various formats.
60///
61/// # Performance Characteristics
62/// - **DashMap**: Lock-free concurrent access for allocations
63/// - **Atomic counters**: Lock-free statistics updates
64/// - **Thread-local**: Zero-contention for per-thread tracking
65pub struct MemoryTracker {
66    /// Live bindings per address, oldest first (DashMap for lock-free concurrent
67    /// access).
68    ///
69    /// One address can hold more than one live binding: when a second allocation is
70    /// observed at an address whose previous block was never released, both blocks are
71    /// facts. Keeping them apart (instead of overwriting) is what makes
72    /// `(ptr, binding_serial)` a usable identity (plan PR-02).
73    active_allocations: DashMap<usize, Vec<AllocationInfo>>,
74
75    /// Atomic statistics (lock-free updates)
76    total_allocations: AtomicU64,
77    total_allocated: AtomicU64,
78    total_deallocations: AtomicU64,
79    total_deallocated: AtomicU64,
80    peak_allocations: AtomicUsize,
81    peak_memory: AtomicU64,
82    /// Number of live bindings (an address may hold several). Kept as a counter so the
83    /// peak-update path stays O(1); scanning the map per allocation would make the hot
84    /// path quadratic in the number of addresses.
85    live_bindings: AtomicUsize,
86
87    /// Number of sightings of an already tracked address that did not change its
88    /// size (duplicate `track!`, allocator hook + typed observation of the same
89    /// buffer). Used as a diagnostic: it must never reach the byte counters.
90    duplicate_observations: AtomicU64,
91    /// Monotonic identity assigned to every binding this tracker creates.
92    next_binding_serial: AtomicU64,
93    /// Number of allocations that arrived at an address which still had a live
94    /// binding. Reported as a diagnostic: the capture missed a `free` (or saw two
95    /// different blocks at one address), so no release can be attributed reliably.
96    replaced_bindings: AtomicU64,
97    /// Number of releases that could only be matched by address, because the address
98    /// held more than one live binding. Those closes are guesses and are reported so a
99    /// consumer never mistakes them for an exact release (plan PR-02).
100    degraded_releases: AtomicU64,
101
102    /// Fast mode flag for testing (reduces overhead)
103    fast_mode: AtomicU64,
104}
105
106impl MemoryTracker {
107    /// Create a new memory tracker.
108    pub fn new() -> Self {
109        let fast_mode = std::env::var("MEMSCOPE_TEST_MODE").is_ok() || cfg!(test);
110
111        Self {
112            active_allocations: DashMap::new(),
113            total_allocations: AtomicU64::new(0),
114            total_allocated: AtomicU64::new(0),
115            total_deallocations: AtomicU64::new(0),
116            total_deallocated: AtomicU64::new(0),
117            peak_allocations: AtomicUsize::new(0),
118            peak_memory: AtomicU64::new(0),
119            live_bindings: AtomicUsize::new(0),
120            next_binding_serial: AtomicU64::new(1),
121            duplicate_observations: AtomicU64::new(0),
122            replaced_bindings: AtomicU64::new(0),
123            degraded_releases: AtomicU64::new(0),
124            fast_mode: AtomicU64::new(fast_mode as u64),
125        }
126    }
127
128    /// Track a memory allocation or a typed observation of one.
129    ///
130    /// # Re-observation vs. address reuse
131    ///
132    /// The same address can be reported more than once: a `track!` on a buffer the
133    /// allocator hook already recorded, two `track!` calls on the same variable, or a
134    /// *different* block that landed on a reused address whose `free` was lost.
135    ///
136    /// * Identical size → duplicate observation: metadata refresh only, no accounting.
137    /// * Different size → **new binding**. An observation cannot prove that the old
138    ///   block is still the same block, so the previous row leaves the live set and the
139    ///   new one gets its own identity. Reusing the row ("in-place realloc") would merge
140    ///   two allocations, and a later release by raw address would then delete the wrong
141    ///   one. Only [`MemoryTracker::track_reallocation`] may claim identity continuity,
142    ///   because only the allocator can observe it.
143    ///
144    /// This keeps `total_allocated - total_deallocated == Σ live sizes` (plan A06) while
145    /// never inventing a generation merge.
146    ///
147    /// # Arguments
148    /// * `ptr` - Memory pointer address
149    /// * `size` - Allocation size in bytes
150    pub fn track_allocation(&self, ptr: usize, size: usize) -> TrackingResult<()> {
151        self.record_binding(ptr, size, None)
152    }
153
154    /// Shared bookkeeping for `track_allocation` / `fast_track_allocation`.
155    fn record_binding(
156        &self,
157        ptr: usize,
158        size: usize,
159        var_name: Option<String>,
160    ) -> TrackingResult<()> {
161        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
162
163        let mut entries = self.active_allocations.entry(ptr).or_default();
164
165        if let Some(existing) = entries.last_mut() {
166            if existing.size == size {
167                // Same address, same size: another observation of the same block.
168                if var_name.is_some() {
169                    existing.var_name = var_name;
170                }
171                self.duplicate_observations.fetch_add(1, Ordering::Relaxed);
172                return Ok(());
173            }
174
175            // Same address, different size, no observed release in between: either an
176            // address reuse whose release was lost, or an in-place reallocation. An
177            // observation cannot tell them apart, so both blocks stay live and the
178            // condition is reported. Merging them would destroy the old identity and
179            // make a later release delete the wrong one.
180            self.replaced_bindings.fetch_add(1, Ordering::Relaxed);
181            tracing::debug!(
182                "conflicting allocation: address {:#x} still holds a live binding",
183                ptr
184            );
185        }
186
187        let mut allocation = AllocationInfo::new(ptr, size);
188        allocation.binding_serial = self.next_binding_serial.fetch_add(1, Ordering::Relaxed);
189        if var_name.is_some() {
190            allocation.var_name = var_name;
191        }
192        entries.push(allocation);
193        drop(entries);
194        self.live_bindings.fetch_add(1, Ordering::Relaxed);
195
196        self.total_allocations.fetch_add(1, Ordering::Relaxed);
197        self.total_allocated
198            .fetch_add(size as u64, Ordering::Relaxed);
199        self.update_peak_allocations();
200        self.update_peak_memory();
201        Ok(())
202    }
203
204    /// Number of live bindings across all addresses.
205    fn live_binding_count(&self) -> usize {
206        self.live_bindings.load(Ordering::Relaxed)
207    }
208
209    /// Number of live bindings currently recorded at `ptr`.
210    pub fn live_binding_count_at(&self, ptr: usize) -> usize {
211        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
212        self.active_allocations
213            .get(&ptr)
214            .map(|entries| entries.len())
215            .unwrap_or(0)
216    }
217
218    /// Refresh the high-water mark for the number of live allocations.
219    fn update_peak_allocations(&self) {
220        // CAS loop avoids the TOCTOU race of read-then-store.
221        loop {
222            let current_count = self.live_binding_count();
223            let current_peak = self.peak_allocations.load(Ordering::Relaxed);
224            if current_count <= current_peak {
225                break;
226            }
227            if self
228                .peak_allocations
229                .compare_exchange_weak(
230                    current_peak,
231                    current_count,
232                    Ordering::Relaxed,
233                    Ordering::Relaxed,
234                )
235                .is_ok()
236            {
237                break;
238            }
239        }
240    }
241
242    /// Refresh the high-water mark for live bytes.
243    fn update_peak_memory(&self) {
244        loop {
245            let current_memory = self
246                .total_allocated
247                .load(Ordering::Relaxed)
248                .saturating_sub(self.total_deallocated.load(Ordering::Relaxed));
249            let current_peak_memory = self.peak_memory.load(Ordering::Relaxed);
250            if current_memory <= current_peak_memory {
251                break;
252            }
253            if self
254                .peak_memory
255                .compare_exchange_weak(
256                    current_peak_memory,
257                    current_memory,
258                    Ordering::Relaxed,
259                    Ordering::Relaxed,
260                )
261                .is_ok()
262            {
263                break;
264            }
265        }
266    }
267
268    /// Track a memory deallocation by address.
269    ///
270    /// This is the **degraded** path: an address alone cannot name a binding, so when
271    /// the address holds more than one live binding the oldest one is closed and the
272    /// guess is counted in [`Self::degraded_releases`]. Callers that know which block
273    /// they are releasing must use [`Self::track_deallocation_of`] (plan PR-02).
274    ///
275    /// # Returns
276    /// * `Ok(true)` if a binding was found and closed
277    /// * `Ok(false)` if the pointer was not tracked (possible double-free or untracked
278    ///   allocation)
279    pub fn track_deallocation(&self, ptr: usize) -> TrackingResult<bool> {
280        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
281        Ok(self.close_binding(ptr, None).is_some())
282    }
283
284    /// Close a binding and report exactly which one was closed.
285    ///
286    /// The facade needs the identity that was *actually* released: with several live
287    /// bindings at one address the newest one is not the one a degraded release closes,
288    /// so publishing `binding_serial(ptr)` there would describe the wrong block.
289    pub fn close_and_report(
290        &self,
291        ptr: usize,
292        serial: Option<u64>,
293    ) -> TrackingResult<Option<ClosedBinding>> {
294        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
295        Ok(self.close_binding(ptr, serial))
296    }
297
298    /// The live binding recorded at exactly `ptr` (newest binding at that address).
299    ///
300    /// O(1): one map lookup. Use this for deduplication and charging decisions instead of
301    /// the range scan.
302    pub fn binding_at(&self, ptr: usize) -> Option<ClosedBinding> {
303        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
304        self.active_allocations.get(&ptr).and_then(|entries| {
305            entries.last().map(|binding| ClosedBinding {
306                ptr,
307                serial: binding.binding_serial,
308                size: binding.size,
309            })
310        })
311    }
312
313    /// The live binding whose block contains `addr`, if the candidate is unique.
314    ///
315    /// A smart pointer's pointee sits *inside* its allocation (an `Arc` puts the value
316    /// after the two reference counts), so matching by address or size cannot find the
317    /// block. The containing range can.
318    pub fn binding_containing(&self, addr: usize) -> Option<ClosedBinding> {
319        let _gate = crate::core::allocator::capture_gate::acquire(addr);
320        let mut candidates: Vec<ClosedBinding> = Vec::new();
321        for entry in self.active_allocations.iter() {
322            for binding in entry.value() {
323                let start = binding.ptr;
324                let end = start.saturating_add(binding.size);
325                if addr >= start && addr < end {
326                    candidates.push(ClosedBinding {
327                        ptr: start,
328                        serial: binding.binding_serial,
329                        size: binding.size,
330                    });
331                }
332            }
333        }
334
335        // Only a single candidate names an identity. Two nested blocks (an old 64-byte
336        // binding still live under a new 128-byte one) both contain the address, and
337        // picking "the smallest" would systematically annotate the older generation.
338        // Ambiguity is reported instead of guessed.
339        match candidates.len() {
340            1 => candidates.pop(),
341            0 => None,
342            count => {
343                tracing::debug!(
344                    "ambiguous containment at {:#x}: {count} live blocks contain it",
345                    addr
346                );
347                None
348            }
349        }
350    }
351
352    /// Track a memory deallocation of one specific binding.
353    ///
354    /// `serial` is the identity returned by [`Self::binding_serial`]; only that binding
355    /// may be closed, so a release that arrives after the address was reused cannot
356    /// delete the new block.
357    pub fn track_deallocation_of(&self, ptr: usize, serial: u64) -> TrackingResult<bool> {
358        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
359        Ok(self.close_binding(ptr, Some(serial)).is_some())
360    }
361
362    /// Close a binding, returning the closed record when one matched.
363    fn close_binding(&self, ptr: usize, serial: Option<u64>) -> Option<ClosedBinding> {
364        let closed = {
365            let mut entries = self.active_allocations.get_mut(&ptr)?;
366
367            let index = match serial {
368                Some(serial) => entries
369                    .iter()
370                    .position(|binding| binding.binding_serial == serial),
371                None => {
372                    if entries.len() > 1 {
373                        self.degraded_releases.fetch_add(1, Ordering::Relaxed);
374                        tracing::debug!(
375                            "degraded release: address {:#x} holds {} bindings, closing the oldest",
376                            ptr,
377                            entries.len()
378                        );
379                    }
380                    Some(0)
381                }
382            };
383
384            let Some(index) = index else {
385                // The named binding is already gone: a late release of a superseded
386                // generation must not touch the blocks that replaced it.
387                return None;
388            };
389
390            let closed = entries.remove(index);
391            if entries.is_empty() {
392                drop(entries);
393                self.active_allocations.remove(&ptr);
394            }
395            closed
396        };
397
398        self.live_bindings.fetch_sub(1, Ordering::Relaxed);
399        self.total_deallocations.fetch_add(1, Ordering::Relaxed);
400        self.total_deallocated
401            .fetch_add(closed.size as u64, Ordering::Relaxed);
402        Some(ClosedBinding {
403            ptr: closed.ptr,
404            serial: closed.binding_serial,
405            size: closed.size,
406        })
407    }
408
409    /// Track a reallocation of a tracked block.
410    ///
411    /// The block keeps its identity (and its binding serial): the new size replaces the
412    /// old one and the allocation counter does not change, whether the allocator kept the
413    /// address (`old_ptr == new_ptr`) or moved the block. Metadata recorded by `track!` is
414    /// carried over to the new address.
415    ///
416    /// # Arguments
417    /// * `old_ptr` - Address before the reallocation
418    /// * `new_ptr` - Address returned by the allocator
419    /// * `old_size` - Size the caller asked to reallocate. Kept for API symmetry; the
420    ///   cache trusts the size it recorded, and an unobserved block books nothing.
421    /// * `new_size` - Size of the resulting block
422    pub fn track_reallocation(
423        &self,
424        old_ptr: usize,
425        new_ptr: usize,
426        _old_size: usize,
427        new_size: usize,
428    ) -> TrackingResult<()> {
429        let _gate = crate::core::allocator::capture_gate::acquire(old_ptr);
430
431        let previous = {
432            let Some(mut entries) = self.active_allocations.get_mut(&old_ptr) else {
433                return self.open_unmatched_binding(new_ptr, new_size);
434            };
435            // A reallocation applies to the block the caller holds; the newest binding at
436            // the address is the only defensible choice when the capture also holds older
437            // generations.
438            let previous = entries.pop();
439            if entries.is_empty() {
440                drop(entries);
441                self.active_allocations.remove(&old_ptr);
442            }
443            previous
444        };
445
446        let Some(mut allocation) = previous else {
447            return self.open_unmatched_binding(new_ptr, new_size);
448        };
449
450        // A matched reallocation keeps one ledger row: the old size leaves the live
451        // set, the new size enters it, and the allocation count does not change.
452        let previous_size = allocation.size;
453        allocation.ptr = new_ptr;
454        allocation.size = new_size;
455        self.active_allocations
456            .entry(new_ptr)
457            .or_default()
458            .push(allocation);
459
460        self.total_allocated
461            .fetch_add(new_size as u64, Ordering::Relaxed);
462        self.total_deallocated
463            .fetch_add(previous_size as u64, Ordering::Relaxed);
464        self.update_peak_memory();
465        Ok(())
466    }
467
468    /// Record a block whose previous size was never observed.
469    ///
470    /// Only the new block is a fact, so nothing may be booked as released for the old
471    /// one (that would push `allocated - deallocated` below the live set).
472    fn open_unmatched_binding(&self, new_ptr: usize, new_size: usize) -> TrackingResult<()> {
473        let mut new_binding = AllocationInfo::new(new_ptr, new_size);
474        new_binding.binding_serial = self.next_binding_serial.fetch_add(1, Ordering::Relaxed);
475        self.active_allocations
476            .entry(new_ptr)
477            .or_default()
478            .push(new_binding);
479        self.live_bindings.fetch_add(1, Ordering::Relaxed);
480        self.total_allocations.fetch_add(1, Ordering::Relaxed);
481        self.total_allocated
482            .fetch_add(new_size as u64, Ordering::Relaxed);
483        self.update_peak_allocations();
484        self.update_peak_memory();
485        Ok(())
486    }
487
488    /// Get the size of an active allocation.
489    ///
490    /// # Arguments
491    /// * `ptr` - Memory pointer address
492    ///
493    /// # Returns
494    /// * `Some(size)` if the allocation exists
495    /// * `None` if the pointer is not tracked
496    pub fn get_allocation_size(&self, ptr: usize) -> Option<usize> {
497        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
498        self.active_allocations
499            .get(&ptr)
500            .and_then(|entries| entries.last().map(|binding| binding.size))
501    }
502
503    /// Associate a variable name and type with an allocation.
504    ///
505    /// # Arguments
506    /// * `ptr` - Memory pointer address
507    /// * `var_name` - Variable name
508    /// * `type_name` - Type name
509    /// * `source_file` - Source file (optional)
510    /// * `source_line` - Source line (optional)
511    pub fn associate_var(
512        &self,
513        ptr: usize,
514        var_name: String,
515        type_name: String,
516        source_file: Option<&str>,
517        source_line: Option<u32>,
518    ) -> TrackingResult<()> {
519        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
520        if let Some(mut entries) = self.active_allocations.get_mut(&ptr) {
521            if let Some(allocation) = entries.last_mut() {
522                allocation.var_name = Some(var_name);
523                allocation.type_name = Some(type_name);
524                if let (Some(file), Some(line)) = (source_file, source_line) {
525                    allocation.set_source_location(file, line);
526                }
527            }
528        }
529
530        Ok(())
531    }
532
533    /// Fast track allocation with variable name.
534    ///
535    /// # Arguments
536    /// * `ptr` - Memory pointer address
537    /// * `size` - Allocation size in bytes
538    /// * `var_name` - Variable name
539    pub fn fast_track_allocation(
540        &self,
541        ptr: usize,
542        size: usize,
543        var_name: String,
544    ) -> TrackingResult<()> {
545        self.record_binding(ptr, size, Some(var_name))
546    }
547
548    /// Identity of the live binding at `ptr`, if any.
549    ///
550    /// Changes whenever a *new* block is recorded at that address, so callers can detect
551    /// address reuse instead of assuming the same allocation (plan A06/PR-02).
552    pub fn binding_serial(&self, ptr: usize) -> Option<u64> {
553        let _gate = crate::core::allocator::capture_gate::acquire(ptr);
554        self.active_allocations
555            .get(&ptr)
556            .and_then(|entries| entries.last().map(|binding| binding.binding_serial))
557    }
558
559    /// Number of duplicate sightings of already tracked addresses.
560    ///
561    /// A non-zero value is expected (hook registration plus typed observation of
562    /// the same buffer); it is reported separately so it can never be mistaken for
563    /// allocated bytes.
564    pub fn duplicate_observations(&self) -> u64 {
565        self.duplicate_observations.load(Ordering::Relaxed)
566    }
567
568    /// Number of bindings superseded at the same address without an observed release.
569    ///
570    /// Non-zero means the capture missed a `free`, or two different blocks shared an
571    /// address: the bytes stay conserved, but the two allocations are not the same one.
572    pub fn replaced_bindings(&self) -> u64 {
573        self.replaced_bindings.load(Ordering::Relaxed)
574    }
575
576    /// Number of releases that could only be matched by address.
577    ///
578    /// Non-zero means a release arrived for an address that held several live bindings,
579    /// so the closed binding was a guess. Callers that know the identity should use
580    /// `track_deallocation_of` instead.
581    pub fn degraded_releases(&self) -> u64 {
582        self.degraded_releases.load(Ordering::Relaxed)
583    }
584
585    /// Get current memory statistics.
586    pub fn get_stats(&self) -> TrackingResult<MemoryStats> {
587        let _gate = crate::core::allocator::capture_gate::acquire(0);
588        let active_count = self.live_binding_count();
589        let total_allocated = self.total_allocated.load(Ordering::Relaxed);
590        let total_deallocated = self.total_deallocated.load(Ordering::Relaxed);
591        let active_memory = total_allocated.saturating_sub(total_deallocated);
592
593        Ok(MemoryStats {
594            total_allocations: self.total_allocations.load(Ordering::Relaxed),
595            total_allocated,
596            active_allocations: active_count,
597            active_memory,
598            peak_allocations: self.peak_allocations.load(Ordering::Relaxed),
599            peak_memory: self.peak_memory.load(Ordering::Relaxed),
600            total_deallocations: self.total_deallocations.load(Ordering::Relaxed),
601            total_deallocated,
602            leaked_allocations: 0,
603            leaked_memory: 0,
604        })
605    }
606
607    /// Detect memory leaks at program shutdown.
608    ///
609    /// This should be called when the program is shutting down to detect
610    /// allocations that were never freed. Returns the count and total size
611    /// of allocations that are still active.
612    pub fn detect_leaks(&self) -> (usize, u64) {
613        let _gate = crate::core::allocator::capture_gate::acquire(0);
614        let active_count = self.live_binding_count();
615        let total_allocated = self.total_allocated.load(Ordering::Relaxed);
616        let total_deallocated = self.total_deallocated.load(Ordering::Relaxed);
617        let active_memory = total_allocated.saturating_sub(total_deallocated);
618        (active_count, active_memory)
619    }
620
621    /// Get all currently active allocations.
622    pub fn get_active_allocations(&self) -> TrackingResult<Vec<AllocationInfo>> {
623        let _gate = crate::core::allocator::capture_gate::acquire(0);
624        let mut allocations = Vec::new();
625        for entry in self.active_allocations.iter() {
626            allocations.extend(entry.value().iter().cloned());
627        }
628        Ok(allocations)
629    }
630
631    /// Get memory grouped by type.
632    pub fn get_memory_by_type(&self) -> TrackingResult<std::collections::HashMap<String, usize>> {
633        let _gate = crate::core::allocator::capture_gate::acquire(0);
634        let mut type_sizes: std::collections::HashMap<String, usize> =
635            std::collections::HashMap::new();
636
637        for entry in self.active_allocations.iter() {
638            for alloc in entry.value() {
639                let type_name = alloc
640                    .type_name
641                    .clone()
642                    .unwrap_or_else(|| "unknown".to_string());
643                *type_sizes.entry(type_name).or_insert(0) += alloc.size;
644            }
645        }
646
647        Ok(type_sizes)
648    }
649
650    /// Enable or disable fast mode.
651    pub fn set_fast_mode(&self, enabled: bool) {
652        self.fast_mode.store(enabled as u64, Ordering::Relaxed);
653    }
654
655    /// Check if fast mode is enabled.
656    pub fn is_fast_mode(&self) -> bool {
657        self.fast_mode.load(Ordering::Relaxed) != 0
658    }
659
660    /// Enable fast mode for testing.
661    pub fn enable_fast_mode(&self) {
662        self.fast_mode.store(1, Ordering::Relaxed);
663    }
664
665    /// Ensure memory analysis path exists and return the full path.
666    pub fn ensure_memory_analysis_path<P: AsRef<std::path::Path>>(
667        &self,
668        path: P,
669    ) -> std::path::PathBuf {
670        let path = path.as_ref();
671        let base_dir = path
672            .parent()
673            .unwrap_or(std::path::Path::new("MemoryAnalysis"));
674
675        if let Err(e) = std::fs::create_dir_all(base_dir) {
676            tracing::warn!("Failed to create directory {:?}: {}", base_dir, e);
677        }
678
679        if base_dir == std::path::Path::new("") {
680            std::path::Path::new("MemoryAnalysis").join(path.file_name().unwrap_or_default())
681        } else {
682            path.to_path_buf()
683        }
684    }
685
686    /// Ensure path uses .memscope extension and is in MemoryAnalysis directory.
687    pub fn ensure_memscope_path<P: AsRef<std::path::Path>>(&self, path: P) -> std::path::PathBuf {
688        let mut output_path = self.ensure_memory_analysis_path(path);
689
690        if output_path.extension().is_none()
691            || output_path.extension() != Some(std::ffi::OsStr::new("memscope"))
692        {
693            output_path.set_extension("memscope");
694        }
695
696        output_path
697    }
698
699    /// Export memory tracking data to .memscope file format (JSON content).
700    ///
701    /// This method exports memory tracking data with a .memscope file extension.
702    /// The content is serialized as JSON for human readability and interoperability.
703    pub fn export_to_memscope<P: AsRef<std::path::Path>>(&self, path: P) -> TrackingResult<()> {
704        self.export_to_json(path)
705    }
706
707    /// Export memory tracking data to JSON format.
708    pub fn export_to_json<P: AsRef<std::path::Path>>(&self, path: P) -> TrackingResult<()> {
709        let output_path = self.ensure_memory_analysis_path(&path);
710
711        let final_path = if output_path.is_dir() {
712            output_path.join("memory_analysis.json")
713        } else {
714            output_path
715        };
716
717        let allocations = self.get_active_allocations()?;
718
719        let json = serde_json::to_string_pretty(&allocations)
720            .map_err(|e| TrackingError::SerializationError(e.to_string()))?;
721
722        std::fs::write(&final_path, json).map_err(|e| TrackingError::ExportError(e.to_string()))?;
723
724        Ok(())
725    }
726}
727
728impl Default for MemoryTracker {
729    fn default() -> Self {
730        Self::new()
731    }
732}
733
734impl Drop for MemoryTracker {
735    fn drop(&mut self) {
736        if std::env::var("MEMSCOPE_VERBOSE").is_ok() {
737            tracing::info!(
738                "💡 Tip: Use tracker.export_to_json() before drop to save analysis results"
739            );
740        }
741
742        let active_count = self.live_binding_count();
743        if active_count > 0 {
744            tracing::warn!(
745                "Dropping MemoryTracker with {} active allocations (potential memory leaks)",
746                active_count
747            );
748        }
749
750        // Clear active_allocations to release memory
751        self.active_allocations.clear();
752        self.live_bindings.store(0, Ordering::Relaxed);
753    }
754}
755
756/// Configure tracking strategy for the application.
757///
758/// # Arguments
759/// * `is_concurrent` - true for multi-threaded/async applications, false for single-threaded
760pub fn configure_tracking_strategy(is_concurrent: bool) {
761    let strategy = if is_concurrent {
762        STRATEGY_THREAD_LOCAL
763    } else {
764        STRATEGY_GLOBAL_SINGLETON
765    };
766
767    TRACKING_STRATEGY.store(strategy, Ordering::Relaxed);
768
769    tracing::info!(
770        "Configured tracking strategy: {}",
771        if is_concurrent {
772            "thread-local"
773        } else {
774            "global-singleton"
775        }
776    );
777}
778
779/// The process-wide core tracker, regardless of the configured strategy.
780///
781/// The global allocator callback must use this and never [`get_tracker`]: touching a
782/// thread-local with a destructor from the allocator aborts the process on macOS
783/// ("the global allocator may not use TLS with destructors"), and a thread-local cache
784/// cannot resolve a binding allocated on another thread.
785pub fn get_global_tracker() -> Arc<MemoryTracker> {
786    GLOBAL_TRACKER
787        .get_or_init(|| Arc::new(MemoryTracker::new()))
788        .clone()
789}
790
791/// Get the appropriate memory tracker based on the current strategy.
792///
793/// # Returns
794/// * In single-threaded mode: returns the global singleton tracker
795/// * In concurrent mode: returns the current thread's local tracker
796pub fn get_tracker() -> Arc<MemoryTracker> {
797    match TRACKING_STRATEGY.load(Ordering::Relaxed) {
798        STRATEGY_GLOBAL_SINGLETON => GLOBAL_TRACKER
799            .get_or_init(|| Arc::new(MemoryTracker::new()))
800            .clone(),
801        STRATEGY_THREAD_LOCAL => THREAD_LOCAL_TRACKER.with(|tracker| tracker.clone()),
802        _ => {
803            tracing::warn!("Unknown tracking strategy, falling back to global singleton");
804            GLOBAL_TRACKER
805                .get_or_init(|| Arc::new(MemoryTracker::new()))
806                .clone()
807        }
808    }
809}
810
811/// Collect all thread-local trackers.
812pub fn collect_all_trackers_local() -> Vec<Arc<MemoryTracker>> {
813    get_local_registry()
814        .iter()
815        .map(|entry| entry.value().clone())
816        .collect()
817}
818
819/// Get registry statistics.
820pub fn get_registry_stats_local() -> ThreadRegistryStats {
821    let registry = get_local_registry();
822    let total_threads = registry.len();
823
824    ThreadRegistryStats {
825        total_threads_registered: total_threads,
826        active_threads: total_threads,
827        dead_references: 0,
828    }
829}
830
831/// Check if there are active trackers.
832pub fn has_active_trackers_local() -> bool {
833    !get_local_registry().is_empty()
834}
835
836#[cfg(test)]
837mod tests;