Skip to main content

how_far_along/
tree.rs

1use crate::{
2    Execution, Outcome, PhaseSpec, PlanError, Report, Total,
3    sync::{Counter, MetadataCell},
4};
5use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec};
6use core::{
7    fmt,
8    sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering},
9};
10
11/// A phase's identity within one job. The root is [`NodeId::ROOT`]; children
12/// are numbered in planning order. Profiling spans use it to refer to phases.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct NodeId(usize);
15
16impl NodeId {
17    /// The root phase of every job.
18    pub const ROOT: Self = Self(0);
19
20    /// The identifier as a number, as written in JSON exports.
21    pub const fn get(self) -> usize {
22        self.0
23    }
24}
25
26impl fmt::Display for NodeId {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        self.0.fmt(f)
29    }
30}
31
32/// Lifecycle state, independent of the counted fraction.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum Status {
36    /// Work has not started.
37    #[default]
38    NotStarted,
39    /// Started explicitly or by the first nonzero report.
40    Running,
41    /// A frozen terminal observation.
42    Finished(Outcome),
43}
44
45/// A lightweight observation of weighted progress, without names or allocations.
46#[derive(Clone, Copy, Debug, PartialEq)]
47#[non_exhaustive]
48pub struct Summary {
49    /// Known weighted progress; unresolved children contribute zero.
50    /// None only for a leaf whose count denominator is unknown or overflowed.
51    /// Read `unresolved_fraction` alongside this value to expose missing evidence.
52    pub fraction: Option<f64>,
53    /// Share whose denominator is unknown or invalid.
54    pub unresolved_fraction: f64,
55    /// State of the observed phase, independent of its fraction.
56    pub status: Status,
57}
58/// Whether an outcome alone fixes a phase's fraction, whatever its children did.
59fn credited(status: Status) -> bool {
60    matches!(
61        status,
62        Status::Finished(Outcome::Succeeded | Outcome::Skipped | Outcome::NotRun)
63    )
64}
65/// The fraction of a leaf, or of a phase whose outcome fixes it.
66fn leaf_fraction(status: Status, total: Total, completed: u64, overflowed: bool) -> Option<f64> {
67    match (status, total) {
68        (Status::Finished(Outcome::Succeeded | Outcome::Skipped), _) => Some(1.0),
69        (Status::Finished(Outcome::NotRun), _) => Some(0.0),
70        _ if overflowed => None,
71        (_, Total::Exact(0) | Total::Estimated(0)) => Some(0.0),
72        (_, Total::Exact(n) | Total::Estimated(n)) => Some((completed as f64 / n as f64).min(1.0)),
73        _ => None,
74    }
75}
76
77#[derive(Clone)]
78struct Metadata {
79    units: Name,
80    total: Total,
81    revisions: Vec<Total>,
82    execution: Execution,
83    children: Vec<Arc<Node>>,
84    /// A snapshot taken at the outcome, kept only when the subtree could still
85    /// change afterwards (see [`Phase::seal`]).
86    frozen: Option<Snapshot>,
87}
88
89/// Outcomes as stored in [`Node::outcome`]; `UNKNOWN` is an outcome this
90/// version cannot name, which is kept in a frozen snapshot instead.
91const UNKNOWN: u8 = u8::MAX;
92fn encode(outcome: Outcome) -> u8 {
93    match outcome {
94        Outcome::Succeeded => 0,
95        Outcome::Skipped => 1,
96        Outcome::Cancelled => 2,
97        Outcome::Failed => 3,
98        Outcome::Abandoned => 4,
99        Outcome::NotRun => 5,
100        _ => UNKNOWN,
101    }
102}
103fn decode(code: u8) -> Option<Outcome> {
104    match code {
105        0 => Some(Outcome::Succeeded),
106        1 => Some(Outcome::Skipped),
107        2 => Some(Outcome::Cancelled),
108        3 => Some(Outcome::Failed),
109        4 => Some(Outcome::Abandoned),
110        5 => Some(Outcome::NotRun),
111        _ => None,
112    }
113}
114/// How many recent total revisions a phase keeps.
115const MAX_REVISIONS: usize = 16;
116
117/// A phase's name or units, inline when short, as most are, so naming a
118/// phase rarely allocates.
119#[derive(Clone)]
120enum Name {
121    Inline { len: u8, bytes: [u8; INLINE_NAME] },
122    Heap(Box<str>),
123}
124const INLINE_NAME: usize = 22;
125impl Name {
126    fn new(name: &str) -> Self {
127        match name.len() {
128            len @ ..=INLINE_NAME => {
129                let mut bytes = [0; INLINE_NAME];
130                bytes[..len].copy_from_slice(name.as_bytes());
131                Self::Inline {
132                    len: len as u8,
133                    bytes,
134                }
135            }
136            _ => Self::Heap(name.into()),
137        }
138    }
139    fn as_str(&self) -> &str {
140        match self {
141            // Always valid: a whole `str` was copied in.
142            Self::Inline { len, bytes } => {
143                core::str::from_utf8(&bytes[..usize::from(*len)]).unwrap_or_default()
144            }
145            Self::Heap(name) => name,
146        }
147    }
148}
149
150struct Node {
151    id: NodeId,
152    parent: Option<NodeId>,
153    name: Name,
154    weight: u64,
155    initial_total: Total,
156    next_id: Arc<AtomicUsize>,
157    issued: AtomicBool,
158    branch: AtomicBool,
159    // 0=pending, 1=running, 2=terminal. `outcome` and `final_count` are
160    // written before the state becomes terminal.
161    state: AtomicU8,
162    outcome: AtomicU8,
163    inferred: AtomicBool,
164    completed: Counter,
165    /// The count at the outcome; a report racing with the finish is not shown.
166    final_count: Counter,
167    meta: MetadataCell<Metadata>,
168}
169impl Node {
170    fn new(
171        id: NodeId,
172        parent: Option<NodeId>,
173        spec: &PhaseSpec<'_>,
174        next_id: Arc<AtomicUsize>,
175    ) -> Self {
176        Self {
177            id,
178            parent,
179            name: Name::new(spec.name),
180            weight: spec.weight,
181            initial_total: spec.total,
182            next_id,
183            issued: AtomicBool::new(false),
184            branch: AtomicBool::new(false),
185            state: AtomicU8::new(0),
186            outcome: AtomicU8::new(UNKNOWN),
187            inferred: AtomicBool::new(false),
188            completed: Counter::new(),
189            final_count: Counter::new(),
190            meta: MetadataCell::new(Metadata {
191                units: Name::new(spec.units),
192                total: spec.total,
193                revisions: Vec::new(),
194                execution: spec.execution,
195                children: Vec::new(),
196                frozen: None,
197            }),
198        }
199    }
200    fn snapshot(&self) -> Snapshot {
201        self.snapshot_with(false)
202            .expect("blocking metadata read succeeds")
203    }
204    /// Status, counter and metadata. The state is read first: a terminal state
205    /// guarantees the metadata read next includes any frozen snapshot.
206    fn read(&self, nonblocking: bool) -> Option<(Status, &Counter, Arc<Metadata>)> {
207        let state = self.state.load(Ordering::Acquire);
208        let meta = if nonblocking {
209            self.meta.try_get()?
210        } else {
211            self.meta.get()
212        };
213        Some(match state {
214            0 => (Status::NotStarted, &self.completed, meta),
215            1 => (Status::Running, &self.completed, meta),
216            // Without a frozen snapshot, a terminal outcome is always encoded.
217            _ => {
218                let outcome = decode(self.outcome.load(Ordering::Relaxed));
219                let status = Status::Finished(outcome.unwrap_or(Outcome::Abandoned));
220                (status, &self.final_count, meta)
221            }
222        })
223    }
224    fn snapshot_with(&self, nonblocking: bool) -> Option<Snapshot> {
225        let (status, counter, meta) = self.read(nonblocking)?;
226        if let Some(frozen) = &meta.frozen {
227            return Some(frozen.clone());
228        }
229        let mut result = Snapshot {
230            id: self.id,
231            parent: self.parent,
232            name: self.name.as_str().into(),
233            weight: self.weight,
234            units: meta.units.as_str().into(),
235            total: meta.total,
236            initial_total: self.initial_total,
237            total_revisions: meta.revisions.clone(),
238            execution: meta.execution,
239            status,
240            completed: counter.get(),
241            overflowed: counter.overflowed(),
242            completion_inferred: self.inferred.load(Ordering::Relaxed),
243            children: Vec::with_capacity(meta.children.len()),
244        };
245        for child in &meta.children {
246            let child = child.snapshot_with(nonblocking)?;
247            if result.status == Status::NotStarted && child.status != Status::NotStarted {
248                // A pending branch whose child has started is running.
249                result.status = Status::Running;
250            }
251            result.children.push(child);
252        }
253        Some(result)
254    }
255    fn summary(&self, nonblocking: bool) -> Option<Summary> {
256        // Counters are numeric observations, never synchronization for
257        // application data.
258        let (status, counter, meta) = self.read(nonblocking)?;
259        if let Some(frozen) = &meta.frozen {
260            return Some(Summary {
261                fraction: frozen.fraction(),
262                unresolved_fraction: frozen.unresolved_fraction(),
263                status: frozen.status,
264            });
265        }
266        let fraction = leaf_fraction(status, meta.total, counter.get(), counter.overflowed());
267        let mut result = Summary {
268            fraction,
269            unresolved_fraction: if fraction.is_some() { 0.0 } else { 1.0 },
270            status,
271        };
272        if meta.children.is_empty() {
273            return Some(result);
274        }
275        let mut sum = 0.0;
276        for child in &meta.children {
277            sum += child.weight as f64;
278        }
279        let mut fraction = 0.0;
280        let mut unresolved = 0.0;
281        for child in &meta.children {
282            let observed = child.summary(nonblocking)?;
283            if result.status == Status::NotStarted && observed.status != Status::NotStarted {
284                result.status = Status::Running;
285            }
286            let weight = child.weight as f64 / sum;
287            fraction += weight * observed.fraction.unwrap_or(0.0);
288            unresolved += weight * observed.unresolved_fraction;
289        }
290        if !credited(status) {
291            result.fraction = Some(fraction.clamp(0.0, 1.0));
292            result.unresolved_fraction = unresolved;
293        }
294        Some(result)
295    }
296    fn finished(&self) -> bool {
297        self.state.load(Ordering::Acquire) == 2
298    }
299}
300
301/// The owner of one phase: its plan, its counter, and its outcome.
302///
303/// Give workers cloned [`Reporter`]s, not the owner. Join workers and flush
304/// their batches before [`finish`](Self::finish). Dropping an unfinished owner
305/// records [`Outcome::Abandoned`]. Each attempt uses a new phase, so a stale
306/// reporter from an earlier attempt can never change a new one.
307///
308/// Phases are owned and `'static`: move children into spawned threads or
309/// tasks, or wrap any phase in a [`PulseTree`](crate::PulseTree) to hand it to
310/// a library as `&dyn Pulse`.
311#[must_use = "finish the phase after its work joins; dropping it unfinished records Abandoned"]
312pub struct Phase {
313    node: Arc<Node>,
314}
315impl Phase {
316    /// Start a job: a pending root leaf that counts `items`.
317    pub fn new(name: impl Into<String>, total: Total) -> Self {
318        let name: String = name.into();
319        Self {
320            node: Arc::new(Node::new(
321                NodeId::ROOT,
322                None,
323                &PhaseSpec::new(&name, 1, total),
324                Arc::new(AtomicUsize::new(1)),
325            )),
326        }
327    }
328    /// A cheap, cloneable counter for this phase's workers.
329    ///
330    /// Taking one ends planning: the phase can no longer split. A branch's
331    /// reporter ignores reports, so work is never counted twice.
332    pub fn reporter(&self) -> Reporter {
333        self.node.issued.store(true, Ordering::Relaxed);
334        self.deferred_reporter()
335    }
336    /// A reporter that does not end planning until its first report.
337    pub(crate) fn deferred_reporter(&self) -> Reporter {
338        Reporter {
339            node: Arc::clone(&self.node),
340        }
341    }
342    /// Observe this subtree from any thread.
343    pub fn observer(&self) -> Observer {
344        Observer {
345            node: Arc::clone(&self.node),
346        }
347    }
348    /// This phase's identity within its job.
349    pub fn id(&self) -> NodeId {
350        self.node.id
351    }
352    /// Mark the phase running before its first report, for work that starts
353    /// long before it can count anything. A report starts a leaf on its own.
354    pub fn start(&mut self) -> Result<(), PlanError> {
355        self.ensure_live()?;
356        self.node.state.store(1, Ordering::Release);
357        Ok(())
358    }
359    /// Name the counted unit, before planning or counting begins.
360    pub fn set_units(&mut self, units: impl Into<String>) -> Result<(), PlanError> {
361        self.ensure_unused()?;
362        let units: String = units.into();
363        let mut meta = (*self.node.meta.get()).clone();
364        meta.units = Name::new(&units);
365        self.node.meta.publish(meta);
366        Ok(())
367    }
368    /// Declare how this phase's work is scheduled, before planning or counting.
369    pub fn set_execution(&mut self, execution: Execution) -> Result<(), PlanError> {
370        self.ensure_unused()?;
371        let mut meta = (*self.node.meta.get()).clone();
372        meta.execution = execution;
373        self.node.meta.publish(meta);
374        Ok(())
375    }
376    /// Revise a leaf's total while it runs.
377    ///
378    /// The most recent revisions are kept in [`Snapshot::total_revisions`].
379    /// A fraction can go down after a revision; smoothing is the display's job.
380    pub fn set_total(&mut self, total: Total) -> Result<(), PlanError> {
381        self.ensure_live()?;
382        if self.node.branch.load(Ordering::Relaxed) {
383            return Err(PlanError::NotALeaf);
384        }
385        let mut meta = (*self.node.meta.get()).clone();
386        if meta.total != total {
387            meta.total = total;
388            // Bounded, so a streaming estimate revised per chunk stays cheap
389            // to revise, snapshot and export.
390            if meta.revisions.len() == MAX_REVISIONS {
391                meta.revisions.remove(0);
392            }
393            meta.revisions.push(total);
394            self.node.meta.publish(meta);
395        }
396        Ok(())
397    }
398    /// Split an unused leaf into weighted children, returned as an array.
399    ///
400    /// ```
401    /// use how_far_along::{Execution, Phase, PhaseSpec, Total};
402    /// let mut job = Phase::new("encode", Total::Unknown);
403    /// let [before, middle, after] = job.split(Execution::Sequence, [
404    ///     PhaseSpec::new("prepare", 35, Total::Exact(1)),
405    ///     PhaseSpec::new("parallel", 30, Total::Unknown),
406    ///     PhaseSpec::new("write", 35, Total::Exact(1)),
407    /// ])?;
408    /// # Ok::<(), how_far_along::PlanError>(())
409    /// ```
410    pub fn split<const N: usize>(
411        &mut self,
412        execution: Execution,
413        parts: [PhaseSpec<'_>; N],
414    ) -> Result<[Phase; N], PlanError> {
415        match self.split_vec(execution, &parts)?.try_into() {
416            Ok(children) => Ok(children),
417            Err(_) => unreachable!("one child per part"),
418        }
419    }
420    /// Split an unused leaf into a runtime-sized list of children.
421    ///
422    /// A split is final: a running phase cannot gain siblings later. Keep
423    /// discovery in an `Unknown`-total leaf until the full list is known.
424    pub fn split_vec(
425        &mut self,
426        execution: Execution,
427        parts: &[PhaseSpec<'_>],
428    ) -> Result<Vec<Phase>, PlanError> {
429        let mut children = Vec::with_capacity(parts.len());
430        self.split_each(execution, parts, &mut |child| children.push(child))?;
431        Ok(children)
432    }
433    /// [`split_vec`](Self::split_vec), handing each child to `each` in order
434    /// instead of collecting them.
435    #[allow(deprecated)] // Atomic::try_update is newer than the Rust 1.88 MSRV.
436    pub(crate) fn split_each(
437        &mut self,
438        execution: Execution,
439        parts: &[PhaseSpec<'_>],
440        each: &mut dyn FnMut(Phase),
441    ) -> Result<(), PlanError> {
442        self.ensure_unused()?;
443        PhaseSpec::validate_split(parts)?;
444        let first = self
445            .node
446            .next_id
447            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
448                n.checked_add(parts.len())
449            })
450            .map_err(|_| PlanError::Overflow)?;
451        let mut nodes = Vec::with_capacity(parts.len());
452        for (i, part) in parts.iter().enumerate() {
453            nodes.push(Arc::new(Node::new(
454                NodeId(first + i),
455                Some(self.node.id),
456                part,
457                Arc::clone(&self.node.next_id),
458            )));
459        }
460        let mut meta = (*self.node.meta.get()).clone();
461        meta.execution = execution;
462        // A branch has no total of its own, so neither does its history.
463        meta.total = Total::Unknown;
464        meta.revisions.clear();
465        for node in &nodes {
466            each(Phase {
467                node: Arc::clone(node),
468            });
469        }
470        meta.children = nodes;
471        self.node.meta.publish(meta);
472        self.node.branch.store(true, Ordering::Release);
473        Ok(())
474    }
475    /// Record success after every worker joins and every child finishes.
476    /// Failed child attempts may be retained under a recovered successful parent.
477    pub fn finish(&mut self) -> Result<(), PlanError> {
478        self.finish_with(Outcome::Succeeded)
479    }
480    /// Record a terminal outcome. The subtree's snapshot stops changing.
481    ///
482    /// Every child must have finished first. Their outcomes need not match the
483    /// parent's: the library decides whether recovery succeeded. A failed call
484    /// changes nothing, so the owner can fix the cause and finish again.
485    /// `Cancelled` records how the work ended; it does not request a stop.
486    pub fn finish_with(&mut self, outcome: Outcome) -> Result<(), PlanError> {
487        self.ensure_live()?;
488        for child in &self.node.meta.get().children {
489            if !child.finished() {
490                return Err(PlanError::UnfinishedChildren);
491            }
492        }
493        self.seal(outcome);
494        Ok(())
495    }
496
497    pub(crate) fn record_outcome(&mut self, outcome: Outcome) -> Result<(), PlanError> {
498        self.ensure_live()?;
499        self.seal(outcome);
500        Ok(())
501    }
502
503    pub(crate) fn complete_inferred(&mut self, outcome: Outcome) -> Result<(), PlanError> {
504        self.ensure_live()?;
505        self.node.inferred.store(true, Ordering::Relaxed);
506        let outcome = if self
507            .node
508            .summary(false)
509            .is_some_and(|s| s.status == Status::Running)
510        {
511            Outcome::Abandoned
512        } else {
513            outcome
514        };
515        self.record_outcome(outcome)
516    }
517    /// Make `outcome` terminal. When every child has finished, the subtree can
518    /// no longer change, so atomics suffice and nothing is allocated. A phase
519    /// dropped while a child runs keeps a snapshot taken now, so its view does
520    /// not follow the orphaned child; so does an outcome this version cannot
521    /// encode.
522    fn seal(&mut self, outcome: Outcome) {
523        let code = encode(outcome);
524        let mut running_child = false;
525        for child in &self.node.meta.get().children {
526            if !child.finished() {
527                running_child = true;
528            }
529        }
530        if code == UNKNOWN || running_child {
531            let mut snapshot = self.node.snapshot();
532            resolve_children(&mut snapshot, outcome);
533            snapshot.status = Status::Finished(outcome);
534            let mut meta = (*self.node.meta.get()).clone();
535            meta.frozen = Some(snapshot);
536            self.node.meta.publish(meta);
537        }
538        self.node.final_count.copy_from(&self.node.completed);
539        self.node.outcome.store(code, Ordering::Relaxed);
540        self.node.state.store(2, Ordering::Release);
541    }
542    fn ensure_live(&self) -> Result<(), PlanError> {
543        if self.node.finished() {
544            Err(PlanError::Finished)
545        } else {
546            Ok(())
547        }
548    }
549    fn ensure_unused(&self) -> Result<(), PlanError> {
550        self.ensure_live()?;
551        if self.node.issued.load(Ordering::Relaxed) || self.node.branch.load(Ordering::Relaxed) {
552            Err(PlanError::AlreadyInUse)
553        } else {
554            Ok(())
555        }
556    }
557}
558impl Drop for Phase {
559    fn drop(&mut self) {
560        if !self.node.finished() {
561            self.seal(Outcome::Abandoned);
562        }
563    }
564}
565
566impl how_far::Complete for Phase {
567    fn complete_as(mut self, outcome: Outcome) {
568        let _ = self.record_outcome(outcome);
569    }
570}
571
572// Freeze the parent's observation only. Retained child owners can still close
573// normally, but cannot rewrite what a completed parent observed at its boundary.
574fn resolve_children(snapshot: &mut Snapshot, parent: Outcome) {
575    for child in &mut snapshot.children {
576        let outcome = match child.status {
577            Status::Finished(_) => continue,
578            Status::NotStarted => match parent {
579                Outcome::Succeeded | Outcome::Skipped => Outcome::Skipped,
580                Outcome::Abandoned => Outcome::Abandoned,
581                _ => Outcome::NotRun,
582            },
583            Status::Running => Outcome::Abandoned,
584        };
585        resolve_children(child, outcome);
586        child.status = Status::Finished(outcome);
587        child.completion_inferred = true;
588    }
589}
590/// A cloneable, thread-safe counter for one phase, from [`Phase::reporter`].
591///
592/// `advance` is a saturating atomic update: no clock, callback, lock, or tree walk.
593/// Counts saturate and record the overflow. Reports after the phase finishes,
594/// and reports to a phase that split, are ignored.
595#[derive(Clone)]
596pub struct Reporter {
597    node: Arc<Node>,
598}
599impl fmt::Debug for Reporter {
600    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601        f.debug_struct("Reporter")
602            .field("phase", &self.node.id)
603            .finish_non_exhaustive()
604    }
605}
606impl Report for Reporter {
607    #[inline]
608    #[track_caller]
609    #[allow(clippy::collapsible_match)] // Keep the first-report transition explicit.
610    fn advance(&self, completed: u64) {
611        if completed == 0 || self.node.branch.load(Ordering::Relaxed) {
612            return;
613        }
614        // Store only once, so workers sharing a phase do not keep taking
615        // its cache line for a flag that is already set.
616        if !self.node.issued.load(Ordering::Relaxed) {
617            self.node.issued.store(true, Ordering::Relaxed);
618        }
619        match self.node.state.load(Ordering::Acquire) {
620            2 => return,
621            0 => {
622                if self
623                    .node
624                    .state
625                    .compare_exchange(0, 1, Ordering::Relaxed, Ordering::Relaxed)
626                    == Err(2)
627                {
628                    return;
629                }
630            }
631            _ => {}
632        }
633        self.node.completed.add(completed);
634    }
635    fn may_report(&self) -> bool {
636        !self.node.branch.load(Ordering::Relaxed)
637    }
638}
639
640/// A read-only, cloneable view of a phase and its subtree, usable from any
641/// thread. Holding one keeps the job's records alive.
642#[derive(Clone)]
643pub struct Observer {
644    node: Arc<Node>,
645}
646impl fmt::Debug for Observer {
647    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
648        f.debug_struct("Observer")
649            .field("phase", &self.node.id)
650            .finish_non_exhaustive()
651    }
652}
653impl Observer {
654    /// The observed phase's identity.
655    pub fn id(&self) -> NodeId {
656        self.node.id
657    }
658    /// Read weighted progress without allocating or copying phase names.
659    /// Walks this subtree; sample at a display cadence, not on every report.
660    pub fn summary(&self) -> Summary {
661        self.node.summary(false).expect("blocking read")
662    }
663    /// Read a summary without waiting for a metadata lock. Retry on a later turn.
664    pub fn try_summary(&self) -> Option<Summary> {
665        self.node.summary(true)
666    }
667    /// This phase's stable name; duplicate names are distinguished by `id`.
668    pub fn name(&self) -> &str {
669        self.node.name.as_str()
670    }
671
672    /// Take a snapshot now.
673    ///
674    /// Each phase's metadata is internally consistent; counters in different
675    /// phases are read one after another, not atomically together. A finished
676    /// subtree's snapshot is frozen. This allocates and walks the subtree, so
677    /// the caller chooses how often to sample.
678    pub fn snapshot(&self) -> Snapshot {
679        self.node.snapshot()
680    }
681    /// Take a snapshot without waiting for a lock held by another thread.
682    ///
683    /// Returns `None` if any phase's metadata is being replaced at that moment;
684    /// try again on a later UI or event-loop turn. It never spins. Without
685    /// `std`, entry into the platform's critical section is up to its provider;
686    /// allocation and the tree walk happen outside it.
687    pub fn try_snapshot(&self) -> Option<Snapshot> {
688        self.node.snapshot_with(true)
689    }
690    /// Whether the phase has an outcome, without walking the tree.
691    pub fn is_finished(&self) -> bool {
692        self.node.finished()
693    }
694    /// Observers of this phase's planned children, in declared order; empty
695    /// for a leaf. Copies one list of handles, without walking the subtree.
696    pub fn children(&self) -> Vec<Observer> {
697        let meta = self.node.meta.get();
698        let mut children = Vec::with_capacity(meta.children.len());
699        for node in &meta.children {
700            children.push(Observer {
701                node: Arc::clone(node),
702            });
703        }
704        children
705    }
706}
707
708/// A point-in-time copy of a phase and its subtree.
709///
710/// Fractions measure weighted, counted work. They are not elapsed time and
711/// not an ETA: a nearly finished fork-join can still wait on one slow branch.
712#[derive(Clone, Debug, PartialEq)]
713#[non_exhaustive]
714pub struct Snapshot {
715    /// This phase's identity within its job.
716    pub id: NodeId,
717    /// The parent's identity; `None` for the root.
718    pub parent: Option<NodeId>,
719    /// Phase label.
720    pub name: String,
721    /// Weight relative to its siblings, fixed when the parent split.
722    pub weight: u64,
723    /// Name of the counted unit.
724    pub units: String,
725    /// The current total.
726    pub total: Total,
727    /// The total declared when the phase was planned.
728    pub initial_total: Total,
729    /// The most recent revisions of a leaf's total, oldest first: at most 16.
730    /// Empty for a branch, which has no total of its own.
731    pub total_revisions: Vec<Total>,
732    /// How this phase's work is scheduled.
733    pub execution: Execution,
734    /// Lifecycle. Counting to 100% does not finish a phase; an outcome does.
735    pub status: Status,
736    /// Units reported so far, saturating at [`Snapshot::counter_max`].
737    pub completed: u64,
738    /// The true count exceeded the counter, so no fraction is valid.
739    pub overflowed: bool,
740    /// Completion inferred from the parent's boundary, rather than reported by
741    /// this owner. Diagnostics use this as evidence, not proof of a library bug.
742    pub completion_inferred: bool,
743    /// Children, in declared order.
744    pub children: Vec<Snapshot>,
745}
746impl Snapshot {
747    /// Write this tree as versioned JSON: plan, weights, units, totals and
748    /// their revisions, outcomes, and overflow flags.
749    ///
750    /// Counts and weights are decimal strings, so 64-bit values survive
751    /// JavaScript. Later versions may add keys; readers should ignore keys they
752    /// do not know.
753    pub fn write_json(&self, out: &mut impl fmt::Write) -> fmt::Result {
754        out.write_str("{\"schema_version\":2,\"root\":")?;
755        self.write_node_json(out)?;
756        out.write_char('}')
757    }
758    fn write_node_json(&self, out: &mut impl fmt::Write) -> fmt::Result {
759        use crate::json::{outcome_name, quote};
760        write!(out, "{{\"id\":{},\"parent\":", self.id)?;
761        match self.parent {
762            Some(id) => write!(out, "{id}")?,
763            None => out.write_str("null")?,
764        }
765        out.write_str(",\"name\":")?;
766        quote(out, &self.name)?;
767        write!(out, ",\"weight\":\"{}\",\"units\":", self.weight)?;
768        quote(out, &self.units)?;
769        out.write_str(",\"total\":")?;
770        write_total(out, self.total)?;
771        out.write_str(",\"initial_total\":")?;
772        write_total(out, self.initial_total)?;
773        out.write_str(",\"total_revisions\":[")?;
774        for (i, total) in self.total_revisions.iter().enumerate() {
775            if i > 0 {
776                out.write_char(',')?;
777            }
778            write_total(out, *total)?;
779        }
780        let (execution, capacity) = match self.execution {
781            Execution::Unspecified => ("Unspecified", None),
782            Execution::Sequence => ("Sequence", None),
783            Execution::ForkJoin => ("ForkJoin", None),
784            Execution::WorkPool {
785                max_parallelism, ..
786            } => ("WorkPool", Some(max_parallelism)),
787            _ => ("Other", None),
788        };
789        write!(out, "],\"execution\":\"{execution}\",\"max_parallelism\":")?;
790        match capacity {
791            Some(n) => write!(out, "{n}")?,
792            None => out.write_str("null")?,
793        }
794        let (status, outcome) = match self.status {
795            Status::NotStarted => ("NotStarted", None),
796            Status::Running => ("Running", None),
797            Status::Finished(outcome) => ("Finished", Some(outcome)),
798        };
799        write!(out, ",\"status\":\"{status}\",\"outcome\":")?;
800        match outcome {
801            Some(outcome) => write!(out, "\"{}\"", outcome_name(outcome))?,
802            None => out.write_str("null")?,
803        }
804        write!(out, ",\"completion_inferred\":{}", self.completion_inferred)?;
805        write!(
806            out,
807            ",\"completed\":\"{}\",\"overflowed\":{},\"overrun\":{},\"fraction\":",
808            self.completed,
809            self.overflowed,
810            self.overrun()
811        )?;
812        match self.fraction() {
813            Some(fraction) => write!(out, "{fraction}")?,
814            None => out.write_str("null")?,
815        }
816        write!(
817            out,
818            ",\"unresolved_fraction\":{},\"children\":[",
819            self.unresolved_fraction()
820        )?;
821        for (i, child) in self.children.iter().enumerate() {
822            if i > 0 {
823                out.write_char(',')?;
824            }
825            child.write_node_json(out)?;
826        }
827        out.write_str("]}")
828    }
829    /// Largest per-leaf count representable without a lock on this target:
830    /// `2^63 - 1` with 64-bit atomics (including wasm32), which leaves the
831    /// headroom that lets a report be one `fetch_add`; `usize::MAX`
832    /// otherwise. Exceeding this limit sets `overflowed` rather than wrapping
833    /// or blocking.
834    pub const fn counter_max() -> u64 {
835        crate::sync::COUNT_MAX
836    }
837    /// Known work fraction in [0, 1]. Weighted parents retain each child's
838    /// declared share; unknown or invalid child fractions contribute zero,
839    /// without hiding known siblings or renormalizing their weights.
840    /// Read [`Self::unresolved_fraction`] alongside this value to show the share
841    /// whose progress remains unknown. A leaf with an unknown or invalid count
842    /// denominator returns None. Success/skipping discharges the obligation,
843    /// including zero work.
844    pub fn fraction(&self) -> Option<f64> {
845        if self.children.is_empty() || credited(self.status) {
846            return leaf_fraction(self.status, self.total, self.completed, self.overflowed);
847        }
848        let sum = self.child_weight_sum();
849        let mut result = 0.0;
850        for child in &self.children {
851            result += child.weight as f64 / sum * child.fraction().unwrap_or(0.0);
852        }
853        Some(result.clamp(0.0, 1.0))
854    }
855    /// Fraction of the fixed budget whose count denominator is unresolved.
856    /// This lets consumers display "known work + unknown remainder" honestly.
857    pub fn unresolved_fraction(&self) -> f64 {
858        if self.children.is_empty() {
859            return if self.fraction().is_some() { 0.0 } else { 1.0 };
860        }
861        if credited(self.status) {
862            return 0.0;
863        }
864        let sum = self.child_weight_sum();
865        let mut unresolved = 0.0;
866        for child in &self.children {
867            unresolved += child.weight as f64 / sum * child.unresolved_fraction();
868        }
869        unresolved
870    }
871    fn child_weight_sum(&self) -> f64 {
872        let mut sum = 0.0;
873        for child in &self.children {
874            sum += child.weight as f64;
875        }
876        sum
877    }
878    /// Whether an exact count was exceeded. Success never hides this diagnostic.
879    pub fn overrun(&self) -> bool {
880        matches!(self.total, Total::Exact(n) if self.completed > n) || self.overflowed
881    }
882}
883fn write_total(out: &mut impl fmt::Write, total: Total) -> fmt::Result {
884    match total {
885        Total::Unknown => out.write_str("{\"kind\":\"Unknown\"}"),
886        Total::Exact(n) => write!(out, "{{\"kind\":\"Exact\",\"count\":\"{n}\"}}"),
887        Total::Estimated(n) => write!(out, "{{\"kind\":\"Estimated\",\"count\":\"{n}\"}}"),
888        _ => out.write_str("{\"kind\":\"Other\"}"),
889    }
890}
891
892#[cfg(all(test, feature = "std"))]
893mod tests {
894    use super::*;
895    use crate::Stop;
896    use crate::poll::LocalPoller;
897    use almost_enough::Stopper;
898
899    #[test]
900    fn a_report_racing_with_the_finish_is_not_shown() {
901        let mut leaf = Phase::new("leaf", Total::Exact(3));
902        leaf.reporter().advance(2);
903        leaf.finish().unwrap();
904        // A report that passed the state check just before the finish lands
905        // after it; the finished snapshot keeps the count at the outcome.
906        leaf.node.completed.add(5);
907        let snapshot = leaf.observer().snapshot();
908        assert_eq!(snapshot.completed, 2);
909        assert_eq!(snapshot.status, Status::Finished(Outcome::Succeeded));
910    }
911
912    #[test]
913    fn a_busy_child_skips_ui_snapshots_while_reports_callbacks_and_cancellation_continue() {
914        let mut root = Phase::new("root", Total::Unknown);
915        let [child] = root
916            .split(
917                Execution::Sequence,
918                [PhaseSpec::new("child", 1, Total::Exact(3))],
919            )
920            .unwrap();
921        let observer = root.observer();
922        let stop = Stopper::new();
923        let cancel = stop.clone();
924        let mut poller = LocalPoller::new(observer.clone());
925        poller.subscribe(move |event| {
926            assert!(event.try_snapshot().is_none());
927            assert!(!event.snapshot_materialized());
928            cancel.cancel();
929        });
930        child.node.meta.with_lock_for_test(|| {
931            child.reporter().advance(1);
932            assert!(observer.try_snapshot().is_none());
933            poller.poll();
934            assert!(stop.check().is_err());
935        });
936        assert_eq!(observer.try_snapshot().unwrap().children[0].completed, 1);
937    }
938}