Skip to main content

memscope_rs/
task_registry.rs

1//! Task Registry for unified task tracking
2//!
3//! This module provides a centralized registry for task metadata,
4//! enabling task relationship tracking and memory attribution.
5
6use serde::{Deserialize, Serialize};
7use std::cell::Cell;
8use std::collections::{HashMap, HashSet};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11use std::sync::RwLock;
12
13// Thread-local storage for current task ID
14thread_local! {
15    static CURRENT_TASK_ID: Cell<Option<u64>> = const { Cell::new(None) };
16}
17
18/// Task status
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum TaskStatus {
21    /// Task is currently running
22    Running,
23    /// Task has completed
24    Completed,
25}
26
27/// Task graph node
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TaskNode {
30    /// Task ID
31    pub id: u64,
32    /// Task name
33    pub name: String,
34    /// Memory usage in bytes
35    pub memory_usage: u64,
36    /// Number of allocations
37    pub allocation_count: usize,
38    /// Task status
39    pub status: TaskStatus,
40}
41
42/// Task graph edge (parent-child relationship)
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TaskEdge {
45    /// Parent task ID
46    pub from: u64,
47    /// Child task ID
48    pub to: u64,
49}
50
51/// Task graph
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct TaskGraph {
54    /// Graph nodes (tasks)
55    pub nodes: Vec<TaskNode>,
56    /// Graph edges (parent-child relationships)
57    pub edges: Vec<TaskEdge>,
58}
59
60/// Task metadata
61#[derive(Debug, Clone)]
62pub struct TaskMeta {
63    /// Unique task ID (primary key)
64    pub id: u64,
65    /// Parent task ID (for hierarchy)
66    pub parent: Option<u64>,
67    /// Tokio task ID (optional, for async integration)
68    pub tokio_id: Option<u64>,
69    /// Task name
70    pub name: String,
71    /// Creation timestamp (nanoseconds)
72    pub created_at: u64,
73    /// Actual completion timestamp (nanoseconds), independent of memory events.
74    pub completed_at: Option<u64>,
75    /// Task status
76    pub status: TaskStatus,
77    /// Total memory usage in bytes
78    pub memory_usage: u64,
79    /// Number of allocations
80    pub allocation_count: usize,
81}
82
83impl TaskMeta {
84    /// Create new task metadata
85    pub fn new(id: u64, parent: Option<u64>, name: String) -> Self {
86        Self {
87            id,
88            parent,
89            tokio_id: None,
90            name,
91            created_at: Self::now(),
92            completed_at: None,
93            status: TaskStatus::Running,
94            memory_usage: 0,
95            allocation_count: 0,
96        }
97    }
98
99    /// Get current time in nanoseconds
100    fn now() -> u64 {
101        use std::time::{SystemTime, UNIX_EPOCH};
102        SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .unwrap_or_default()
105            .as_nanos() as u64
106    }
107
108    /// Mark task as completed
109    pub fn mark_completed(&mut self) {
110        self.completed_at.get_or_insert_with(Self::now);
111        self.status = TaskStatus::Completed;
112    }
113
114    /// Record a memory allocation for this task
115    pub fn record_allocation(&mut self, size: usize) {
116        self.memory_usage += size as u64;
117        self.allocation_count += 1;
118    }
119}
120
121/// Per-task accounting derived from the event stream.
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
123pub struct TaskLedgerStats {
124    /// Allocation events attributed to the task.
125    pub allocation_count: u64,
126    /// Bytes of those allocations.
127    pub allocated_bytes: u64,
128    /// Release events attributed to the task.
129    pub deallocation_count: u64,
130    /// Bytes of those releases.
131    pub deallocated_bytes: u64,
132    /// Live bytes still attributed to the task at the cut.
133    pub live_bytes: u64,
134}
135
136/// Result of charging an allocation to the current task.
137///
138/// Three states that used to be one boolean: an allocation can be charged, belong to no
139/// task at all, or be deferred because the registry lock was busy. A report must be able
140/// to tell them apart (plan A08).
141#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum ChargeOutcome {
144    /// The task was charged, `memory_usage` includes this block.
145    Charged,
146    /// No task context was installed; the block belongs to no task.
147    Unattributed,
148    /// The registry lock was busy, so the charge was skipped and counted. The affected
149    /// task's metrics are a lower bound for this capture window.
150    Deferred,
151}
152
153/// Registry charge evidence only; lifecycle completeness is tracked separately.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
155pub struct ChargeQuality {
156    /// Charges skipped because the registry was busy.
157    pub deferred_charges: u64,
158    /// Bytes covered by those skipped charges.
159    pub deferred_bytes: u64,
160    /// Allocations observed without a task context.
161    pub unattributed_charges: u64,
162    /// Bytes of those allocations.
163    pub unattributed_bytes: u64,
164    /// Captured allocation events with no charge outcome (legacy or external producers).
165    pub unknown_charges: u64,
166    /// Bytes whose registry charge outcome was not captured.
167    pub unknown_bytes: u64,
168}
169
170impl ChargeQuality {
171    /// `true` when every observed allocation was charged to a task.
172    #[must_use]
173    pub fn is_complete(&self) -> bool {
174        self.deferred_charges == 0 && self.unattributed_charges == 0 && self.unknown_charges == 0
175    }
176}
177
178/// Global task ID counter
179static TASK_COUNTER: AtomicU64 = AtomicU64::new(1);
180
181/// Global task registry singleton
182static GLOBAL_REGISTRY: std::sync::OnceLock<TaskIdRegistry> = std::sync::OnceLock::new();
183
184/// Get the global task registry instance
185pub fn global_registry() -> &'static TaskIdRegistry {
186    GLOBAL_REGISTRY.get_or_init(TaskIdRegistry::new)
187}
188
189/// Generate a process-wide ID shared by synchronous scopes and async wrappers.
190/// Panics only if the u64 identity space is exhausted; IDs are never recycled.
191pub fn generate_task_id() -> u64 {
192    TASK_COUNTER
193        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
194        .expect("process task identity space exhausted")
195}
196
197/// Task guard for RAII-style task lifecycle management
198///
199/// When dropped, automatically completes the task.
200pub struct TaskGuard {
201    task_id: u64,
202    parent: Option<u64>,
203    tasks: Arc<RwLock<HashMap<u64, TaskMeta>>>,
204}
205
206impl TaskGuard {
207    /// Create a new task guard (internal use)
208    fn new(task_id: u64, parent: Option<u64>, tasks: Arc<RwLock<HashMap<u64, TaskMeta>>>) -> Self {
209        Self {
210            task_id,
211            parent,
212            tasks,
213        }
214    }
215}
216
217impl Drop for TaskGuard {
218    fn drop(&mut self) {
219        TaskIdRegistry::complete_task(&self.tasks, self.task_id);
220
221        // Restore the parent instead of blanking the slot: nested scopes used to
222        // leave the thread with no current task at all, so allocations after an
223        // inner scope ended were silently unattributed (plan §7.2 item 6).
224        // The write is skipped when another task has already taken the slot.
225        CURRENT_TASK_ID.with(|cell| {
226            if cell.get() == Some(self.task_id) {
227                cell.set(self.parent);
228            }
229        });
230    }
231}
232
233/// Task registry for managing task metadata
234pub struct TaskIdRegistry {
235    /// Task metadata storage
236    tasks: Arc<RwLock<HashMap<u64, TaskMeta>>>,
237    /// Set of used task IDs for uniqueness detection
238    used_ids: Arc<RwLock<HashSet<u64>>>,
239    /// Allocator charges skipped because the registry lock was busy. Reported so a
240    /// skipped charge is visible instead of silently lost (plan A08).
241    skipped_charges: std::sync::atomic::AtomicU64,
242    /// Bytes that were part of the skipped charges, so a report can state the size of the
243    /// gap and not only its frequency.
244    skipped_bytes: std::sync::atomic::AtomicU64,
245    /// Allocations observed while no task context was installed: they belong to no task,
246    /// which is different from "the charge was deferred".
247    unattributed_charges: std::sync::atomic::AtomicU64,
248    /// Bytes of the unattributed allocations.
249    unattributed_bytes: std::sync::atomic::AtomicU64,
250    /// Deferred charges per task, so a report can mark *that* task's numbers as a lower
251    /// bound instead of only flagging the whole window.
252    deferred_by_task: dashmap::DashMap<u64, (u64, u64)>,
253}
254
255impl TaskIdRegistry {
256    /// Create new task registry
257    pub fn new() -> Self {
258        Self {
259            tasks: Arc::new(RwLock::new(HashMap::new())),
260            used_ids: Arc::new(RwLock::new(HashSet::new())),
261            skipped_charges: std::sync::atomic::AtomicU64::new(0),
262            skipped_bytes: std::sync::atomic::AtomicU64::new(0),
263            unattributed_charges: std::sync::atomic::AtomicU64::new(0),
264            unattributed_bytes: std::sync::atomic::AtomicU64::new(0),
265            deferred_by_task: dashmap::DashMap::new(),
266        }
267    }
268
269    /// Create a task scope with automatic lifecycle management
270    ///
271    /// This is the simplified API - just call this and the task is automatically
272    /// completed when the guard is dropped.
273    ///
274    /// # Arguments
275    ///
276    /// * `name` - Task name
277    ///
278    /// # Returns
279    ///
280    /// A TaskGuard that automatically completes the task when dropped
281    ///
282    /// # Example
283    ///
284    /// ```rust
285    /// # use memscope_rs::task_registry::global_registry;
286    /// let registry = global_registry();
287    ///
288    /// {
289    ///     let _main = registry.task_scope("main_process");
290    ///     let data = vec![1, 2, 3]; // Automatically attributed to main_process
291    ///
292    ///     {
293    ///         let _worker = registry.task_scope("worker"); // Parent is automatically main_process
294    ///         let more_data = vec![4, 5, 6]; // Automatically attributed to worker
295    ///     } // worker automatically completed
296    /// } // main automatically completed
297    ///
298    /// let graph = registry.export_graph();
299    /// ```
300    pub fn task_scope(&self, name: &str) -> TaskGuard {
301        let parent = Self::current_task_id();
302        let task_id = self.spawn_task(parent, name.to_string());
303        TaskGuard::new(task_id, parent, Arc::clone(&self.tasks))
304    }
305
306    /// Register an explicitly-tracked task.
307    ///
308    /// The parent is inferred from the current thread-local task, and
309    /// `CURRENT_TASK_ID` is set so that subsequent `track!` calls on the
310    /// same thread are attributed to this task. This preserves the original
311    /// contract for existing callers that rely on "register then track"
312    /// semantics.
313    ///
314    /// # Arguments
315    ///
316    /// * `task_id` - Externally-generated task ID
317    /// * `name` - Task name
318    pub fn register_explicit_task(&self, task_id: u64, name: &str) {
319        TASK_COUNTER.fetch_max(task_id.saturating_add(1), Ordering::Relaxed);
320        let parent = Self::current_task_id();
321        let mut meta = TaskMeta::new(task_id, parent, name.to_string());
322
323        if let Some(tokio_id) = self.get_tokio_task_id() {
324            meta.tokio_id = Some(tokio_id);
325        }
326
327        if let Ok(mut tasks) = self.tasks.write() {
328            tasks.insert(task_id, meta);
329        }
330        if let Ok(mut used_ids) = self.used_ids.write() {
331            used_ids.insert(task_id);
332        }
333        CURRENT_TASK_ID.set(Some(task_id));
334    }
335
336    /// Register an explicitly-tracked task with a known parent ID.
337    ///
338    /// Unlike [`register_explicit_task`], this method does NOT read or write
339    /// `CURRENT_TASK_ID`. The caller provides the parent ID directly (which
340    /// should be captured at spawn time, not at first-poll time). The
341    /// poll-level wrapper (`ContextualFuture`) handles context installation.
342    ///
343    /// # Arguments
344    ///
345    /// * `task_id` - Externally-generated task ID
346    /// * `name` - Task name
347    /// * `parent_id` - Parent task ID (None for root tasks)
348    pub fn register_explicit_task_with_parent(
349        &self,
350        task_id: u64,
351        name: &str,
352        parent_id: Option<u64>,
353    ) {
354        TASK_COUNTER.fetch_max(task_id.saturating_add(1), Ordering::Relaxed);
355        let mut meta = TaskMeta::new(task_id, parent_id, name.to_string());
356
357        if let Some(tokio_id) = self.get_tokio_task_id() {
358            meta.tokio_id = Some(tokio_id);
359        }
360
361        if let Ok(mut tasks) = self.tasks.write() {
362            tasks.insert(task_id, meta);
363        }
364        if let Ok(mut used_ids) = self.used_ids.write() {
365            used_ids.insert(task_id);
366        }
367        // Intentionally do NOT set CURRENT_TASK_ID here — the poll-level
368        // wrapper manages per-poll context installation.
369    }
370
371    /// Mark a registered task as completed.
372    ///
373    /// The thread-local slot is only cleared when it still holds this task: another
374    /// task (or a parent scope) may be current on this thread.
375    pub fn unregister_task(&self, task_id: u64) {
376        if let Ok(mut tasks) = self.tasks.write() {
377            if let Some(meta) = tasks.get_mut(&task_id) {
378                meta.mark_completed();
379            }
380        }
381        CURRENT_TASK_ID.with(|cell| {
382            if cell.get() == Some(task_id) {
383                cell.set(None);
384            }
385        });
386    }
387
388    /// Spawn a new task (internal use only)
389    ///
390    /// # Arguments
391    ///
392    /// * `parent` - Parent task ID (None for root tasks)
393    /// * `name` - Task name
394    ///
395    /// # Returns
396    ///
397    /// The new task ID
398    fn spawn_task(&self, parent: Option<u64>, name: String) -> u64 {
399        let task_id = generate_task_id();
400
401        let mut meta = TaskMeta::new(task_id, parent, name);
402
403        // Try to get tokio task ID if available
404        if let Some(tokio_id) = self.get_tokio_task_id() {
405            meta.tokio_id = Some(tokio_id);
406        }
407
408        // Store task metadata
409        if let Ok(mut tasks) = self.tasks.write() {
410            tasks.insert(task_id, meta);
411        }
412
413        // Register ID as used
414        if let Ok(mut used_ids) = self.used_ids.write() {
415            used_ids.insert(task_id);
416        }
417
418        // Set as current task in thread-local cache
419        CURRENT_TASK_ID.set(Some(task_id));
420
421        task_id
422    }
423
424    /// Complete a task (internal use only)
425    ///
426    /// # Arguments
427    ///
428    /// * `task_id` - Task ID to complete
429    fn complete_task(tasks: &RwLock<HashMap<u64, TaskMeta>>, task_id: u64) {
430        if let Ok(mut tasks) = tasks.write() {
431            if let Some(meta) = tasks.get_mut(&task_id) {
432                meta.mark_completed();
433            }
434        }
435
436        // The thread-local slot is restored by `TaskGuard::drop`, which knows the
437        // parent task. Do not blank it here: completing a nested task would drop
438        // the parent context for every following allocation.
439    }
440
441    /// Record a memory allocation for the current task
442    ///
443    /// # Arguments
444    ///
445    /// * `size` - Size of the allocation in bytes
446    pub fn record_allocation(&self, size: usize) {
447        if let Some(task_id) = Self::current_task_id() {
448            if let Ok(mut tasks) = self.tasks.write() {
449                if let Some(meta) = tasks.get_mut(&task_id) {
450                    meta.record_allocation(size);
451                }
452            }
453        }
454    }
455
456    /// Charge an allocation without ever blocking on the registry lock.
457    ///
458    /// The global allocator callback runs while other code may hold the registry read
459    /// lock (and allocate inside it). Taking the write lock there self-deadlocks, so this
460    /// uses `try_write` and reports [`ChargeOutcome::Deferred`] instead of waiting; the
461    /// attempt and its bytes are counted so a report can mark the task metrics as a lower
462    /// bound of this capture window.
463    pub fn try_record_allocation(&self, size: usize) -> ChargeOutcome {
464        let Some(task_id) = Self::current_task_id() else {
465            self.unattributed_charges.fetch_add(1, Ordering::Relaxed);
466            self.unattributed_bytes
467                .fetch_add(size as u64, Ordering::Relaxed);
468            return ChargeOutcome::Unattributed;
469        };
470
471        match self.tasks.try_write() {
472            Ok(mut tasks) => match tasks.get_mut(&task_id) {
473                Some(meta) => {
474                    meta.record_allocation(size);
475                    ChargeOutcome::Charged
476                }
477                // The task ended while the allocation was in flight: nothing to charge.
478                None => ChargeOutcome::Unattributed,
479            },
480            Err(_) => {
481                self.skipped_charges.fetch_add(1, Ordering::Relaxed);
482                self.skipped_bytes.fetch_add(size as u64, Ordering::Relaxed);
483                // Attribute the gap to the task that missed it. This touches only the
484                // DashMap shard, never the registry lock that is currently held, so the
485                // allocator callback stays deadlock-free.
486                let mut entry = self.deferred_by_task.entry(task_id).or_insert((0, 0));
487                entry.0 += 1;
488                entry.1 += size as u64;
489                ChargeOutcome::Deferred
490            }
491        }
492    }
493
494    /// Per-task deferred charges: `task_id -> (count, bytes)`.
495    ///
496    /// A task present here has accounting that is a lower bound for this capture window.
497    #[must_use]
498    pub fn deferred_tasks(&self) -> std::collections::HashMap<u64, (u64, u64)> {
499        // DashMap's iterator keeps the current shard read-locked while collect allocates.
500        // Suppress those internal allocations: a busy task registry would otherwise send
501        // their allocator callbacks back to this shard for a deferred-charge write.
502        let _suppression = crate::core::allocator::TrackingGuard::acquire();
503        self.deferred_by_task
504            .iter()
505            .map(|entry| (*entry.key(), *entry.value()))
506            .collect()
507    }
508
509    /// Completeness of the task accounting observed so far.
510    pub fn charge_quality(&self) -> ChargeQuality {
511        ChargeQuality {
512            deferred_charges: self.skipped_charges.load(Ordering::Relaxed),
513            deferred_bytes: self.skipped_bytes.load(Ordering::Relaxed),
514            unattributed_charges: self.unattributed_charges.load(Ordering::Relaxed),
515            unattributed_bytes: self.unattributed_bytes.load(Ordering::Relaxed),
516            ..ChargeQuality::default()
517        }
518    }
519
520    pub fn current_task_id() -> Option<u64> {
521        CURRENT_TASK_ID.get()
522    }
523
524    /// Set the current task ID on the calling thread's TLS.
525    /// Used by the poll-level wrapper to install the task context
526    /// for each `poll()` invocation.
527    pub fn set_current_task_id(&self, task_id: u64) {
528        CURRENT_TASK_ID.set(Some(task_id));
529    }
530
531    /// Set or clear the current task ID on the calling thread's TLS.
532    /// Used by the poll-level wrapper to restore the previous value
533    /// after a `poll()` returns.
534    pub fn set_current_task_id_opt(&self, task_id: Option<u64>) {
535        CURRENT_TASK_ID.set(task_id);
536    }
537
538    /// Clear all tasks (for testing purposes)
539    pub fn clear(&self) {
540        if let Ok(mut tasks) = self.tasks.write() {
541            tasks.clear();
542        }
543        if let Ok(mut used_ids) = self.used_ids.write() {
544            used_ids.clear();
545        }
546        CURRENT_TASK_ID.set(None);
547    }
548
549    /// Get task metadata by ID
550    ///
551    /// # Arguments
552    ///
553    /// * `task_id` - Task ID
554    ///
555    /// # Returns
556    ///
557    /// Task metadata if found
558    pub fn get_task(&self, task_id: u64) -> Option<TaskMeta> {
559        if let Ok(tasks) = self.tasks.read() {
560            tasks.get(&task_id).cloned()
561        } else {
562            None
563        }
564    }
565
566    /// Get all tasks
567    pub fn get_all_tasks(&self) -> Vec<TaskMeta> {
568        if let Ok(tasks) = self.tasks.read() {
569            tasks.values().cloned().collect()
570        } else {
571            Vec::new()
572        }
573    }
574
575    /// Get task children
576    ///
577    /// # Arguments
578    ///
579    /// * `parent_id` - Parent task ID
580    ///
581    /// # Returns
582    ///
583    /// List of child task IDs
584    pub fn get_children(&self, parent_id: u64) -> Vec<u64> {
585        if let Ok(tasks) = self.tasks.read() {
586            tasks
587                .values()
588                .filter(|meta| meta.parent == Some(parent_id))
589                .map(|meta| meta.id)
590                .collect()
591        } else {
592            Vec::new()
593        }
594    }
595
596    /// Get task parent
597    ///
598    /// # Arguments
599    ///
600    /// * `task_id` - Task ID
601    ///
602    /// # Returns
603    ///
604    /// Parent task ID if found
605    pub fn get_parent(&self, task_id: u64) -> Option<u64> {
606        if let Ok(tasks) = self.tasks.read() {
607            tasks.get(&task_id).and_then(|meta| meta.parent)
608        } else {
609            None
610        }
611    }
612
613    /// Get Tokio task ID (if available)
614    fn get_tokio_task_id(&self) -> Option<u64> {
615        // This will be implemented with tokio integration later
616        // For now, return None
617        None
618    }
619
620    /// Export task graph as JSON
621    ///
622    /// # Returns
623    ///
624    /// TaskGraph containing all tasks and their relationships
625    pub fn export_graph(&self) -> TaskGraph {
626        let mut nodes = Vec::new();
627        let mut edges = Vec::new();
628
629        if let Ok(tasks) = self.tasks.read() {
630            // Build nodes
631            for meta in tasks.values() {
632                nodes.push(TaskNode {
633                    id: meta.id,
634                    name: meta.name.clone(),
635                    memory_usage: meta.memory_usage,
636                    allocation_count: meta.allocation_count,
637                    status: meta.status,
638                });
639            }
640
641            // Build edges (parent-child relationships)
642            for meta in tasks.values() {
643                if let Some(parent_id) = meta.parent {
644                    edges.push(TaskEdge {
645                        from: parent_id,
646                        to: meta.id,
647                    });
648                }
649            }
650        }
651
652        TaskGraph { nodes, edges }
653    }
654
655    /// Get task statistics
656    pub fn get_stats(&self) -> TaskRegistryStats {
657        if let Ok(tasks) = self.tasks.read() {
658            let total = tasks.len();
659            let running = tasks
660                .values()
661                .filter(|m| m.status == TaskStatus::Running)
662                .count();
663            let completed = tasks
664                .values()
665                .filter(|m| m.status == TaskStatus::Completed)
666                .count();
667
668            TaskRegistryStats {
669                total_tasks: total,
670                running_tasks: running,
671                completed_tasks: completed,
672            }
673        } else {
674            TaskRegistryStats::default()
675        }
676    }
677}
678
679impl Default for TaskIdRegistry {
680    fn default() -> Self {
681        Self::new()
682    }
683}
684
685/// Task registry statistics
686#[derive(Debug, Clone, Default)]
687pub struct TaskRegistryStats {
688    pub total_tasks: usize,
689    pub running_tasks: usize,
690    pub completed_tasks: usize,
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696    // The registry is a process-wide singleton: tests that spawn or clear tasks must
697    // not interleave with each other.
698    use serial_test::serial;
699
700    #[test]
701    fn test_task_id_generation() {
702        let id1 = generate_task_id();
703        let id2 = generate_task_id();
704        assert!(id2 > id1);
705    }
706
707    #[test]
708    #[serial]
709    fn test_spawn_task() {
710        let registry = global_registry();
711        registry.clear();
712
713        let task_id = registry.spawn_task(None, "test_task".to_string());
714
715        let meta = registry.get_task(task_id);
716        assert!(meta.is_some());
717        assert_eq!(meta.unwrap().name, "test_task");
718    }
719
720    #[test]
721    #[serial]
722    fn test_parent_child() {
723        let registry = global_registry();
724        registry.clear();
725
726        // Using simplified API
727        {
728            let _parent = registry.task_scope("parent");
729            let parent_id = TaskIdRegistry::current_task_id().unwrap();
730
731            {
732                let _child = registry.task_scope("child");
733                let child_id = TaskIdRegistry::current_task_id().unwrap();
734
735                assert_eq!(registry.get_parent(child_id), Some(parent_id));
736                assert_eq!(registry.get_children(parent_id), vec![child_id]);
737            }
738        }
739    }
740
741    #[test]
742    #[serial]
743    fn test_current_task() {
744        let registry = global_registry();
745        registry.clear();
746
747        assert_eq!(TaskIdRegistry::current_task_id(), None);
748
749        {
750            let _task = registry.task_scope("test");
751            let task_id = TaskIdRegistry::current_task_id();
752            assert!(task_id.is_some());
753        }
754
755        assert_eq!(TaskIdRegistry::current_task_id(), None);
756    }
757
758    #[test]
759    #[serial]
760    fn test_complete_task() {
761        let registry = global_registry();
762        registry.clear();
763
764        let task_id;
765
766        {
767            let _task = registry.task_scope("test");
768            task_id = TaskIdRegistry::current_task_id().unwrap();
769
770            let meta = registry.get_task(task_id).unwrap();
771            assert_eq!(meta.status, TaskStatus::Running);
772        }
773
774        // Task should be completed after guard is dropped
775        let meta = registry.get_task(task_id).unwrap();
776        assert_eq!(meta.status, TaskStatus::Completed);
777    }
778
779    #[test]
780    #[serial]
781    fn test_stats() {
782        let registry = global_registry();
783        registry.clear();
784
785        {
786            let _t1 = registry.task_scope("task1");
787            let _t2 = registry.task_scope("task2");
788
789            let stats = registry.get_stats();
790            assert_eq!(stats.total_tasks, 2);
791            assert_eq!(stats.running_tasks, 2);
792        }
793
794        let stats = registry.get_stats();
795        assert_eq!(stats.completed_tasks, 2);
796        assert_eq!(stats.running_tasks, 0);
797    }
798
799    #[test]
800    #[serial]
801    fn test_export_graph() {
802        let registry = global_registry();
803        registry.clear();
804
805        {
806            let _parent = registry.task_scope("parent");
807            {
808                let _child = registry.task_scope("child");
809            }
810        }
811
812        let graph = registry.export_graph();
813
814        assert_eq!(graph.nodes.len(), 2);
815        assert_eq!(graph.edges.len(), 1);
816    }
817
818    /// Objective: Verify a nested task scope restores the parent context on drop
819    /// (plan §7.2 item 6).
820    /// Invariants: after the inner scope ends the parent is current again; after the
821    /// outer scope ends no task is current.
822    #[test]
823    #[serial]
824    fn test_nested_scope_restores_parent_context() {
825        let registry = global_registry();
826        // `clear()` also resets the thread-local current task.
827        registry.clear();
828
829        {
830            let _parent = registry.task_scope("parent");
831            let parent_id = TaskIdRegistry::current_task_id()
832                .expect("the parent scope must install a current task");
833
834            {
835                let _child = registry.task_scope("child");
836                assert_ne!(
837                    TaskIdRegistry::current_task_id(),
838                    Some(parent_id),
839                    "the child scope must take over the current context"
840                );
841            }
842
843            assert_eq!(
844                TaskIdRegistry::current_task_id(),
845                Some(parent_id),
846                "ending the child scope must restore the parent task"
847            );
848        }
849
850        assert_eq!(
851            TaskIdRegistry::current_task_id(),
852            None,
853            "ending the outermost scope must restore the empty context"
854        );
855    }
856}
857
858#[cfg(test)]
859mod ledger_tests;
860
861mod ledger;
862pub use ledger::{TaskAccountingSnapshot, TaskLifecycleQuality};
863
864mod charge_quality;
865
866#[cfg(all(test, feature = "tracking-allocator"))]
867mod allocator_tests;