Skip to main content

g2g_core/runtime/
observe.rs

1//! Live pipeline telemetry tap (dev tooling).
2//!
3//! The end-of-run [`RunStats`](crate::runtime::RunStats) report answers "how did
4//! the run go" after it finishes. This tap answers "how is it going" while it
5//! runs: an [`Observer`] handed to
6//! [`run_graph_observed`](crate::runtime::run_graph_observed) captures the graph
7//! topology and shares the per-element probes, so a concurrent task (a WebSocket
8//! server, a TUI) can call [`Observer::snapshot`] at any time and read the live
9//! per-element `process()` latency and input-link fill. The probes are the same
10//! lock-free atomics the end-of-run report reads, so a snapshot mid-run costs a
11//! handful of relaxed loads and never stalls an arm.
12//!
13//! std-only: it rides the graph runner, which is `std`-gated, and measured
14//! timing needs the monotonic clock. Events (caps changes, errors, EOS, QoS,
15//! buffering) already flow on the [`Bus`](crate::bus::Bus); the transport pairs a
16//! bus with an observer rather than duplicating the event channel here.
17
18use alloc::string::String;
19use alloc::sync::Arc;
20use alloc::vec::Vec;
21
22use spin::Mutex;
23
24use crate::caps::Caps;
25use crate::graph::NodeKind;
26use crate::runtime::channel::ProbeSlot;
27use crate::runtime::instrument::{EdgeCounters, EdgeCounts, Probe, StageVisit};
28use crate::runtime::ElementLatency;
29
30/// The topology role of a node: the serialization-friendly projection of
31/// [`NodeKind`], dropping the tee / muxer pad counts the topology view carries
32/// on the edges instead.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum NodeRole {
35    Source,
36    Transform,
37    Sink,
38    Tee,
39    Muxer,
40}
41
42impl From<NodeKind> for NodeRole {
43    fn from(k: NodeKind) -> Self {
44        match k {
45            NodeKind::Source => NodeRole::Source,
46            NodeKind::Transform => NodeRole::Transform,
47            NodeKind::Sink => NodeRole::Sink,
48            NodeKind::Tee(_) => NodeRole::Tee,
49            NodeKind::Muxer(_) | NodeKind::FaninSink(_) => NodeRole::Muxer,
50            NodeKind::FanoutSrc(_) => NodeRole::Source,
51        }
52    }
53}
54
55/// A live handle onto a running graph's telemetry. Cloneable (clones share one
56/// `Arc` of state): hand a clone to
57/// [`run_graph_observed`](crate::runtime::run_graph_observed) and keep one to
58/// poll [`snapshot`](Self::snapshot) from another task.
59#[derive(Debug, Clone)]
60pub struct Observer {
61    inner: Arc<Inner>,
62}
63
64#[derive(Debug)]
65struct Inner {
66    start_ns: u64,
67    state: Mutex<State>,
68}
69
70#[derive(Debug, Default)]
71struct State {
72    /// Per node id, aligned with the graph's `NodeId` index space. Empty until
73    /// the runner registers.
74    names: Vec<String>,
75    roles: Vec<NodeRole>,
76    /// Per node id; `None` for a node without a `process()` probe (source / tee /
77    /// muxer) or one the runner did not instrument.
78    probes: Vec<Probe>,
79    edges: Vec<EdgeInfo>,
80    /// Per edge id (aligned with `edges`): the link's content-inspection slot and
81    /// its negotiated caps, for the edge-content preview tap. Empty until the
82    /// runner registers them (after channels are built).
83    edge_probes: Vec<ProbeSlot>,
84    edge_caps: Vec<Caps>,
85    /// Per edge id: the link's live packet / byte / drop counters. `None` for an
86    /// edge the runner did not instrument.
87    edge_counters: Vec<Option<Arc<EdgeCounters>>>,
88    /// The graph-wide default link depth, for the queueing floor a measured
89    /// journey is compared against. `0` until the runner registers it.
90    link_capacity: usize,
91}
92
93impl Observer {
94    pub fn new() -> Self {
95        Self {
96            inner: Arc::new(Inner {
97                start_ns: crate::metrics::monotonic_ns(),
98                state: Mutex::new(State::default()),
99            }),
100        }
101    }
102
103    /// Install the graph's topology and probe set. Called once by the runner
104    /// after negotiation, before any frame flows. `names`, `roles`, and `probes`
105    /// are all indexed by `NodeId`; `probes` holds clones of the arms' `Arc`s, so
106    /// reads see live counters.
107    pub(crate) fn register(
108        &self,
109        names: Vec<String>,
110        roles: Vec<NodeRole>,
111        probes: Vec<Probe>,
112        edges: Vec<EdgeInfo>,
113    ) {
114        let mut s = self.inner.state.lock();
115        s.names = names;
116        s.roles = roles;
117        s.probes = probes;
118        s.edges = edges;
119    }
120
121    /// Install the per-edge content-inspection slots, negotiated caps, and live
122    /// traffic counters, aligned with the edges registered above. Called by the
123    /// runner after the channels are built; separate from
124    /// [`register`](Self::register) because all three live on the links, which
125    /// are created after negotiation.
126    pub(crate) fn register_edges(
127        &self,
128        edge_probes: Vec<ProbeSlot>,
129        edge_caps: Vec<Caps>,
130        edge_counters: Vec<Option<Arc<EdgeCounters>>>,
131    ) {
132        let mut s = self.inner.state.lock();
133        s.edge_probes = edge_probes;
134        s.edge_caps = edge_caps;
135        s.edge_counters = edge_counters;
136    }
137
138    /// Append a node that appeared after [`register`](Self::register): a fan-out
139    /// branch or a fan-in input attached while the run was going (M869). Returns
140    /// its `NodeId`. The three per-node vectors are pushed under one lock hold,
141    /// so a concurrent [`snapshot`](Self::snapshot) sees the node whole or not at
142    /// all, never half of it.
143    pub(crate) fn add_node(&self, name: String, role: NodeRole, probe: Probe) -> usize {
144        let mut s = self.inner.state.lock();
145        let id = s.names.len();
146        s.names.push(name);
147        s.roles.push(role);
148        s.probes.push(probe);
149        id
150    }
151
152    /// Append the link of a node registered by [`add_node`](Self::add_node),
153    /// with its negotiated caps and taps. The incremental analog of
154    /// [`register_edges`](Self::register_edges), under the same single lock hold.
155    pub(crate) fn add_edge(&self, from: usize, to: usize, caps: Caps, tap: EdgeTap) {
156        let mut s = self.inner.state.lock();
157        s.edges.push(EdgeInfo {
158            from,
159            to,
160            ..Default::default()
161        });
162        s.edge_probes.push(tap.probe);
163        s.edge_caps.push(caps);
164        s.edge_counters.push(tap.counters);
165    }
166
167    /// Record the graph-wide default link depth the run was built with, so the
168    /// single-frame waterfall can state the `2 * capacity * frame_period`
169    /// queueing floor its measured total is fighting.
170    pub(crate) fn set_link_capacity(&self, capacity: usize) {
171        self.inner.state.lock().link_capacity = capacity;
172    }
173
174    /// The content-inspection slot for edge `idx`, for installing a
175    /// [`LinkInterceptor`](crate::runtime::LinkInterceptor) that samples packets
176    /// crossing that edge. `None` if the index is out of range.
177    pub fn edge_probe(&self, idx: usize) -> Option<ProbeSlot> {
178        self.inner.state.lock().edge_probes.get(idx).cloned()
179    }
180
181    /// The negotiated caps on edge `idx` (so a preview tap knows how to interpret
182    /// the bytes). `None` if the index is out of range.
183    pub fn edge_caps(&self, idx: usize) -> Option<Caps> {
184        self.inner.state.lock().edge_caps.get(idx).cloned()
185    }
186
187    /// Number of edges registered (0 before the runner registers them).
188    pub fn edge_count(&self) -> usize {
189        self.inner.state.lock().edges.len()
190    }
191
192    /// A read of the current telemetry. Cheap: relaxed atomic loads off the
193    /// shared probes plus a clone of the small topology vectors. An empty
194    /// snapshot (no nodes) before the runner has registered.
195    pub fn snapshot(&self) -> TelemetrySnapshot {
196        let s = self.inner.state.lock();
197        let nodes = s
198            .names
199            .iter()
200            .zip(s.roles.iter())
201            .zip(s.probes.iter())
202            .enumerate()
203            .map(|(id, ((name, role), probe))| NodeTelemetry {
204                id,
205                name: name.clone(),
206                role: *role,
207                latency: probe.as_ref().map(|p| p.snapshot()),
208            })
209            .collect();
210        // Fill each edge's negotiated caps and live counters from the aligned
211        // `edge_caps` / `edge_counters` (present once the runner has registered
212        // them, after negotiation).
213        let edges = s
214            .edges
215            .iter()
216            .enumerate()
217            .map(|(i, e)| {
218                let counters = s.edge_counters.get(i).and_then(|c| c.as_ref());
219                EdgeInfo {
220                    from: e.from,
221                    to: e.to,
222                    caps: s.edge_caps.get(i).map(|c| c.to_gst_string()),
223                    observed_caps: counters
224                        .and_then(|c| c.last_caps())
225                        .map(|c| c.to_gst_string()),
226                    counts: counters.map(|c| c.snapshot()).unwrap_or_default(),
227                }
228            })
229            .collect();
230        TelemetrySnapshot {
231            uptime_ns: crate::metrics::monotonic_ns().saturating_sub(self.inner.start_ns),
232            nodes,
233            edges,
234            journey: assemble_journey(&s),
235        }
236    }
237}
238
239/// The linear prefix of the graph starting at a source: nodes strung together by
240/// single edges. The walk stops at the first fan node (a tee / demux / muxer, or
241/// any node with more than one in- or out-edge) because one input frame becomes
242/// N outputs there and the sequence id no longer identifies the same frame.
243/// Returns the chain and whether it stopped short of a terminal node.
244fn linear_chain(state: &State) -> Option<(Vec<usize>, bool)> {
245    let n = state.roles.len();
246    let mut in_deg = alloc::vec![0usize; n];
247    let mut out_deg = alloc::vec![0usize; n];
248    for e in &state.edges {
249        if e.from < n && e.to < n {
250            out_deg[e.from] += 1;
251            in_deg[e.to] += 1;
252        }
253    }
254    let start = (0..n).find(|&i| in_deg[i] == 0)?;
255    let mut chain = alloc::vec![start];
256    let mut cur = start;
257    while out_deg[cur] == 1 {
258        let Some(next) = state.edges.iter().find(|e| e.from == cur).map(|e| e.to) else {
259            break;
260        };
261        if next >= n
262            || in_deg[next] != 1
263            || matches!(state.roles[next], NodeRole::Tee | NodeRole::Muxer)
264        {
265            break;
266        }
267        chain.push(next);
268        cur = next;
269    }
270    Some((chain, out_deg[cur] != 0))
271}
272
273/// Join one frame's path across the graph's linear prefix. Each stage's probe
274/// keeps a ring of recent [`StageVisit`]s keyed by sequence id; a journey is the
275/// newest id every stage recorded whose stamps are consistent with one frame
276/// flowing downstream. `None` when nothing is recorded (no observer, or too few
277/// frames yet) or when no id survives the consistency check, which is the honest
278/// answer for a graph whose elements restamp.
279fn assemble_journey(state: &State) -> Option<FrameJourney> {
280    let (chain, mut truncated) = linear_chain(state)?;
281    // Leading nodes without records are the source (no `process()`); once stages
282    // have started, a gap would make the next hop a fabricated join, so stop.
283    let mut stages: Vec<(usize, Vec<StageVisit>)> = Vec::new();
284    for &node in &chain {
285        let visits = state
286            .probes
287            .get(node)
288            .and_then(|p| p.as_ref())
289            .map(|p| p.visits())
290            .unwrap_or_default();
291        if visits.is_empty() {
292            if !stages.is_empty() {
293                truncated = true;
294                break;
295            }
296            continue;
297        }
298        stages.push((node, visits));
299    }
300    let (last_node, last_visits) = stages.last()?;
301    truncated |= Some(*last_node) != chain.last().copied();
302    let mut seqs: Vec<u64> = last_visits.iter().map(|v| v.sequence).collect();
303    seqs.sort_unstable();
304    seqs.dedup();
305
306    for &sequence in seqs.iter().rev() {
307        let path: Option<Vec<StageVisit>> = stages
308            .iter()
309            .map(|(_, v)| v.iter().rev().find(|x| x.sequence == sequence).copied())
310            .collect();
311        let Some(path) = path else { continue };
312        if !one_frame_downstream(&path) {
313            continue;
314        }
315        let first = path[0];
316        let last = path[path.len() - 1];
317        let frame_period_ns = mean_period_ns(&stages[0].1);
318        let stage_rows = stages
319            .iter()
320            .zip(path.iter())
321            .map(|((node, _), v)| JourneyStage {
322                node: *node,
323                name: state.names.get(*node).cloned().unwrap_or_default(),
324                wait_ns: v.wait_ns,
325                work_ns: v
326                    .exit_ns
327                    .saturating_sub(v.enter_ns)
328                    .saturating_sub(v.push_wait_ns),
329                blocked_ns: v.push_wait_ns,
330            })
331            .collect();
332        return Some(FrameJourney {
333            sequence,
334            stages: stage_rows,
335            // From the frame being queued for the first measured stage to the
336            // last one finishing it: the span an outside observer would time.
337            total_ns: last
338                .exit_ns
339                .saturating_sub(first.enter_ns.saturating_sub(first.wait_ns)),
340            frame_period_ns,
341            capacity: state.link_capacity,
342            floor_ns: 2 * state.link_capacity as u64 * frame_period_ns,
343            truncated,
344        });
345    }
346    None
347}
348
349/// Whether `path` is consistent with one frame walking downstream: each stage
350/// finishes after it starts, and a stage's frame was queued no earlier than the
351/// upstream stage began producing it. Rejects a coincidental id collision from
352/// an element that restamps its output.
353fn one_frame_downstream(path: &[StageVisit]) -> bool {
354    path.windows(2).all(|w| {
355        w[1].exit_ns >= w[1].enter_ns && w[1].enter_ns.saturating_sub(w[1].wait_ns) >= w[0].enter_ns
356    }) && path[0].exit_ns >= path[0].enter_ns
357}
358
359/// Mean spacing between consecutive frames entering a stage, the measured frame
360/// period the queueing floor is expressed in. `0` with fewer than two records.
361fn mean_period_ns(visits: &[StageVisit]) -> u64 {
362    let (Some(first), Some(last)) = (visits.first(), visits.last()) else {
363        return 0;
364    };
365    let spans = visits.len().saturating_sub(1) as u64;
366    last.enter_ns
367        .saturating_sub(first.enter_ns)
368        .checked_div(spans)
369        .unwrap_or(0)
370}
371
372impl Default for Observer {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378/// The observer-side handles of one link: its content-inspection slot and, when
379/// the runner instrumented it, its live traffic counters.
380#[derive(Debug, Default)]
381pub(crate) struct EdgeTap {
382    pub(crate) probe: ProbeSlot,
383    pub(crate) counters: Option<Arc<EdgeCounters>>,
384}
385
386/// Build a link plus the observer-side taps a hand-built runner registers.
387/// `tap` is false when no observer is attached, leaving the link exactly as
388/// cheap as a bare [`link`](crate::runtime::link).
389pub(crate) fn link_tapped(
390    capacity: usize,
391    tap: bool,
392) -> (
393    crate::runtime::LinkSender,
394    crate::runtime::LinkReceiver,
395    EdgeTap,
396) {
397    let (mut tx, rx) = crate::runtime::link(capacity);
398    let counters = tap.then(|| {
399        let c = Arc::new(EdgeCounters::default());
400        tx.set_counters(c.clone());
401        c
402    });
403    let edge = EdgeTap {
404        probe: tx.probe.clone(),
405        counters,
406    };
407    (tx, rx, edge)
408}
409
410/// One node of a hand-built runner's topology: instance name, role, and the
411/// measured-latency probe of the element behind it (`None` for a source or a
412/// structural node with no `process()`).
413pub(crate) type TapNode = (String, NodeRole, Probe);
414
415/// One link of a hand-built runner's topology: endpoints (indices into the node
416/// list), negotiated caps, and the link's taps.
417pub(crate) type TapEdge = (usize, usize, Caps, EdgeTap);
418
419/// Install a hand-built runner's topology into `obs`. The fan-in / fan-out /
420/// session runners have no `Graph` for the runner to walk, so they describe
421/// their nodes and links directly; the resulting snapshot is the same shape
422/// `run_graph_observed` produces.
423pub(crate) fn register_runner_tap(obs: &Observer, nodes: Vec<TapNode>, edges: Vec<TapEdge>) {
424    let mut names = Vec::with_capacity(nodes.len());
425    let mut roles = Vec::with_capacity(nodes.len());
426    let mut probes = Vec::with_capacity(nodes.len());
427    for (name, role, probe) in nodes {
428        names.push(name);
429        roles.push(role);
430        probes.push(probe);
431    }
432    let mut infos = Vec::with_capacity(edges.len());
433    let mut caps = Vec::with_capacity(edges.len());
434    let mut slots = Vec::with_capacity(edges.len());
435    let mut counters = Vec::with_capacity(edges.len());
436    for (from, to, edge_caps, tap) in edges {
437        infos.push(EdgeInfo {
438            from,
439            to,
440            ..Default::default()
441        });
442        caps.push(edge_caps);
443        slots.push(tap.probe);
444        counters.push(tap.counters);
445    }
446    obs.register(names, roles, probes, infos);
447    obs.register_edges(slots, caps, counters);
448}
449
450/// A point-in-time read of a running graph's telemetry.
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct TelemetrySnapshot {
453    /// Nanoseconds since the observer was created.
454    pub uptime_ns: u64,
455    /// One entry per graph node, in `NodeId` order.
456    pub nodes: Vec<NodeTelemetry>,
457    /// The graph's directed links.
458    pub edges: Vec<EdgeInfo>,
459    /// The newest single frame whose whole path could be joined across stages,
460    /// or `None` when no observer-recorded journey assembles (see
461    /// [`FrameJourney`]).
462    pub journey: Option<FrameJourney>,
463}
464
465/// One frame's measured path through the graph's linear prefix (M851): the
466/// per-stage wait + work + blocked of a *single* frame, as opposed to the
467/// per-stage distributions the aggregate waterfall stacks.
468///
469/// Stages are joined on [`Frame::sequence`](crate::Frame), so the journey only
470/// spans elements that carry the id through. It stops at a fan node (a tee,
471/// demux, or muxer, where one input frame becomes N outputs) and at any element
472/// that restamps, with `truncated` set; nothing past that point is guessed.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct FrameJourney {
475    /// The frame's sequence id, as stamped by the source.
476    pub sequence: u64,
477    /// Per stage, upstream first. The source has no `process()` and so no row;
478    /// its cost shows up as the first stage's `wait_ns`.
479    pub stages: Vec<JourneyStage>,
480    /// Measured end to end: from the frame being queued for the first stage to
481    /// the last stage finishing it.
482    pub total_ns: u64,
483    /// Mean spacing between frames entering the first stage.
484    pub frame_period_ns: u64,
485    /// The graph-wide default link depth (`0` if the runner did not register it).
486    pub capacity: usize,
487    /// `2 * capacity * frame_period_ns`: the queueing floor a bounded link
488    /// imposes regardless of how fast the elements are. A `total_ns` near this
489    /// means the pipeline is capacity-bound, not compute-bound.
490    pub floor_ns: u64,
491    /// The journey covers only part of the graph: it ran into a fan node or a
492    /// stage that did not record this id.
493    pub truncated: bool,
494}
495
496/// One stage of a [`FrameJourney`]: what this one frame cost at one element.
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub struct JourneyStage {
499    /// The element's `NodeId` index.
500    pub node: usize,
501    /// Instance name (`<category>N`).
502    pub name: String,
503    /// How long this frame sat on the element's input link. `0` on an
504    /// uninstrumented (leaky) edge.
505    pub wait_ns: u64,
506    /// How long the element computed on this frame: its `process()` span with
507    /// `blocked_ns` taken out.
508    pub work_ns: u64,
509    /// How long that same `process()` call sat blocked pushing this frame into
510    /// the output link, i.e. downstream backpressure rather than work. `0` for a
511    /// sink, which pushes nowhere.
512    pub blocked_ns: u64,
513}
514
515/// Per-node live telemetry.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct NodeTelemetry {
518    /// The node's `NodeId` index.
519    pub id: usize,
520    /// Instance name (`<category>N`), or empty for an unnamed structural node.
521    pub name: String,
522    pub role: NodeRole,
523    /// Measured `process()` latency + input-link fill. `None` for a node without
524    /// a probe (source / tee / muxer); the inner `proc.count` is `0` when no
525    /// clock has yet timed a frame.
526    pub latency: Option<ElementLatency>,
527}
528
529/// A directed link, by node index, with its negotiated caps (the `to_gst_string`
530/// of the solved per-edge `Caps`) and its live traffic counters. `caps` is `None`
531/// until the runner registers the negotiated solution, and in a topology-only
532/// `EdgeInfo`; `counts` advances as packets cross and is all-zero on an
533/// uninstrumented edge.
534#[derive(Debug, Clone, Default, PartialEq, Eq)]
535pub struct EdgeInfo {
536    pub from: usize,
537    pub to: usize,
538    pub caps: Option<alloc::string::String>,
539    /// The last `CapsChanged` that crossed this link (M980). A stream whose
540    /// geometry only arrives with the data (a demuxed file) negotiates a
541    /// placeholder and refines here, so this is the shape the frames really
542    /// carried. `None` until one crosses, and on an uninstrumented edge.
543    pub observed_caps: Option<alloc::string::String>,
544    pub counts: EdgeCounts,
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::runtime::instrument::ElementProbe;
551
552    #[test]
553    fn snapshot_before_register_is_empty() {
554        let obs = Observer::new();
555        let snap = obs.snapshot();
556        assert!(snap.nodes.is_empty());
557        assert!(snap.edges.is_empty());
558    }
559
560    #[test]
561    fn snapshot_reflects_live_probe_writes() {
562        let obs = Observer::new();
563        let probe = ElementProbe::new(String::from("decode0"));
564        obs.register(
565            alloc::vec![String::from("src0"), String::from("decode0")],
566            alloc::vec![NodeRole::Source, NodeRole::Transform],
567            alloc::vec![None, Some(probe.clone())],
568            alloc::vec![EdgeInfo {
569                from: 0,
570                to: 1,
571                ..Default::default()
572            }],
573        );
574
575        // A read taken before any work: the transform's probe exists but is empty.
576        let before = obs.snapshot();
577        assert_eq!(before.nodes.len(), 2);
578        assert_eq!(before.nodes[0].role, NodeRole::Source);
579        assert!(before.nodes[0].latency.is_none(), "source has no probe");
580        assert_eq!(before.nodes[1].latency.as_ref().unwrap().proc.count, 0);
581
582        // Simulate the arm doing work, then read again through the same handle.
583        probe.record_fill(80);
584        probe.record_fill(100);
585        let after = obs.snapshot();
586        let lat = after.nodes[1].latency.as_ref().unwrap();
587        assert_eq!(lat.fill_max_pct, 100);
588        assert!(lat.fill_mean_pct > 0);
589        assert_eq!(
590            after.edges,
591            alloc::vec![EdgeInfo {
592                from: 0,
593                to: 1,
594                ..Default::default()
595            }]
596        );
597    }
598
599    /// M869: a dynamic runner's arm attaches mid-run, so its node and link join
600    /// an already-registered topology. The append lands both, keyed by the
601    /// returned id, and the arm's live probe reads through the same snapshot.
602    #[test]
603    fn incremental_node_and_edge_join_a_registered_topology() {
604        let obs = Observer::new();
605        obs.register(
606            alloc::vec![String::from("src0")],
607            alloc::vec![NodeRole::Source],
608            alloc::vec![None],
609            Vec::new(),
610        );
611        assert_eq!(obs.snapshot().nodes.len(), 1);
612
613        let probe = ElementProbe::new(String::from("fakesink0"));
614        let counters = Arc::new(EdgeCounters::default());
615        let id = obs.add_node(
616            String::from("fakesink0"),
617            NodeRole::Sink,
618            Some(probe.clone()),
619        );
620        assert_eq!(id, 1, "appended after the registered source");
621        obs.add_edge(
622            0,
623            id,
624            Caps::Klv,
625            EdgeTap {
626                probe: ProbeSlot::default(),
627                counters: Some(counters.clone()),
628            },
629        );
630
631        probe.record_fill(60);
632        counters.record_packet(128, 0);
633
634        let snap = obs.snapshot();
635        assert_eq!(snap.nodes.len(), 2);
636        assert_eq!(snap.nodes[1].name, "fakesink0");
637        assert_eq!(snap.nodes[1].role, NodeRole::Sink);
638        assert_eq!(snap.nodes[1].latency.as_ref().unwrap().fill_max_pct, 60);
639        assert_eq!(snap.edges.len(), 1);
640        assert_eq!((snap.edges[0].from, snap.edges[0].to), (0, 1));
641        assert!(snap.edges[0].caps.is_some(), "late edge carries its caps");
642        assert_eq!(snap.edges[0].counts.packets, 1);
643        assert_eq!(snap.edges[0].counts.bytes, 128);
644        assert!(obs.edge_probe(0).is_some(), "late edge has an inspect slot");
645    }
646
647    /// A three-node chain (source -> transform -> sink) with hand-stamped
648    /// visits: the join picks the newest sequence every stage saw and reports it
649    /// upstream-first, with the floor computed off the measured frame period.
650    #[test]
651    fn journey_joins_one_frame_across_stages() {
652        let obs = Observer::new();
653        let xform = ElementProbe::with_journeys(String::from("scale0"));
654        let sink = ElementProbe::with_journeys(String::from("fakesink0"));
655        obs.register(
656            alloc::vec![
657                String::from("src0"),
658                String::from("scale0"),
659                String::from("fakesink0"),
660            ],
661            alloc::vec![NodeRole::Source, NodeRole::Transform, NodeRole::Sink],
662            alloc::vec![None, Some(xform.clone()), Some(sink.clone())],
663            alloc::vec![
664                EdgeInfo {
665                    from: 0,
666                    to: 1,
667                    ..Default::default()
668                },
669                EdgeInfo {
670                    from: 1,
671                    to: 2,
672                    ..Default::default()
673                },
674            ],
675        );
676        obs.set_link_capacity(4);
677
678        // Two frames, 1000 ns apart, each waiting 100 ns then working 200 ns at
679        // the transform and waiting 50 ns then working 150 ns at the sink.
680        for (seq, base) in [(0u64, 10_000u64), (1, 11_000)] {
681            xform.push_visit(StageVisit {
682                sequence: seq,
683                wait_ns: 100,
684                enter_ns: base,
685                exit_ns: base + 200,
686                push_wait_ns: 60,
687            });
688            sink.push_visit(StageVisit {
689                sequence: seq,
690                wait_ns: 50,
691                enter_ns: base + 250,
692                exit_ns: base + 400,
693                push_wait_ns: 0,
694            });
695        }
696
697        let j = obs.snapshot().journey.expect("journey assembles");
698        assert_eq!(j.sequence, 1, "newest fully-crossed frame");
699        assert!(!j.truncated, "chain reached the sink");
700        // The transform's 200 ns span held 60 ns of downstream backpressure, so
701        // its work segment is the remaining 140 ns.
702        assert_eq!(
703            j.stages
704                .iter()
705                .map(|s| (s.node, s.name.as_str(), s.wait_ns, s.work_ns, s.blocked_ns))
706                .collect::<Vec<_>>(),
707            alloc::vec![(1, "scale0", 100, 140, 60), (2, "fakesink0", 50, 150, 0)],
708        );
709        // Queued for the transform at 11_000-100, done at the sink at 11_400.
710        assert_eq!(j.total_ns, 500);
711        let stage_sum: u64 = j
712            .stages
713            .iter()
714            .map(|s| s.wait_ns + s.work_ns + s.blocked_ns)
715            .sum();
716        assert!(j.total_ns >= stage_sum, "{} >= {}", j.total_ns, stage_sum);
717        assert_eq!(j.frame_period_ns, 1_000, "measured inter-frame spacing");
718        assert_eq!(j.capacity, 4);
719        assert_eq!(j.floor_ns, 2 * 4 * 1_000);
720    }
721
722    /// A downstream stage that saw "sequence 0" before the upstream one ever
723    /// started it is a restamp collision, not one frame's path. The join
724    /// rejects it rather than inventing a hop.
725    #[test]
726    fn journey_rejects_an_inconsistent_join() {
727        let obs = Observer::new();
728        let xform = ElementProbe::with_journeys(String::from("dec0"));
729        let sink = ElementProbe::with_journeys(String::from("fakesink0"));
730        obs.register(
731            alloc::vec![
732                String::from("src0"),
733                String::from("dec0"),
734                String::from("fakesink0"),
735            ],
736            alloc::vec![NodeRole::Source, NodeRole::Transform, NodeRole::Sink],
737            alloc::vec![None, Some(xform.clone()), Some(sink.clone())],
738            alloc::vec![
739                EdgeInfo {
740                    from: 0,
741                    to: 1,
742                    ..Default::default()
743                },
744                EdgeInfo {
745                    from: 1,
746                    to: 2,
747                    ..Default::default()
748                },
749            ],
750        );
751        xform.push_visit(StageVisit {
752            sequence: 0,
753            wait_ns: 0,
754            enter_ns: 5_000,
755            exit_ns: 5_100,
756            push_wait_ns: 0,
757        });
758        sink.push_visit(StageVisit {
759            sequence: 0,
760            wait_ns: 0,
761            enter_ns: 1_000,
762            exit_ns: 1_100,
763            push_wait_ns: 0,
764        });
765        assert!(obs.snapshot().journey.is_none());
766    }
767
768    /// A tee ends the linear chain: the frame's id space forks there, so the
769    /// journey covers the prefix and says so.
770    #[test]
771    fn journey_stops_at_a_fan_node() {
772        let obs = Observer::new();
773        let xform = ElementProbe::with_journeys(String::from("scale0"));
774        obs.register(
775            alloc::vec![
776                String::from("src0"),
777                String::from("scale0"),
778                String::new(),
779                String::from("fakesink0"),
780            ],
781            alloc::vec![
782                NodeRole::Source,
783                NodeRole::Transform,
784                NodeRole::Tee,
785                NodeRole::Sink,
786            ],
787            alloc::vec![None, Some(xform.clone()), None, None],
788            alloc::vec![
789                EdgeInfo {
790                    from: 0,
791                    to: 1,
792                    ..Default::default()
793                },
794                EdgeInfo {
795                    from: 1,
796                    to: 2,
797                    ..Default::default()
798                },
799                EdgeInfo {
800                    from: 2,
801                    to: 3,
802                    ..Default::default()
803                },
804            ],
805        );
806        xform.push_visit(StageVisit {
807            sequence: 3,
808            wait_ns: 10,
809            enter_ns: 900,
810            exit_ns: 1_000,
811            push_wait_ns: 0,
812        });
813        let j = obs.snapshot().journey.expect("prefix assembles");
814        assert_eq!(j.stages.len(), 1, "only the pre-tee stage");
815        assert!(j.truncated, "the tee cut the walk short");
816    }
817
818    #[test]
819    fn journey_absent_without_journey_probes() {
820        let obs = Observer::new();
821        obs.register(
822            alloc::vec![String::from("src0"), String::from("fakesink0")],
823            alloc::vec![NodeRole::Source, NodeRole::Sink],
824            alloc::vec![None, Some(ElementProbe::new(String::from("fakesink0")))],
825            alloc::vec![EdgeInfo {
826                from: 0,
827                to: 1,
828                ..Default::default()
829            }],
830        );
831        assert!(obs.snapshot().journey.is_none());
832    }
833
834    #[test]
835    fn node_role_projects_kind() {
836        assert_eq!(NodeRole::from(NodeKind::Tee(3)), NodeRole::Tee);
837        assert_eq!(NodeRole::from(NodeKind::Muxer(2)), NodeRole::Muxer);
838        assert_eq!(NodeRole::from(NodeKind::Sink), NodeRole::Sink);
839    }
840}