Skip to main content

gate4agent_node_core/
drive_loop_profile.rs

1//! Fixed-window latency/rate profiling for `drive_runtime_until_shutdown`'s
2//! own per-iteration phases -- the top half of the answer to "the node
3//! burns CPU with zero live PTY sessions and there is no number for where."
4//!
5//! `gate4agent_runtime_native::tick_profile` already owns the six phases
6//! *inside* `NativeRuntime::tick`; this module covers the three things that
7//! surround that call in the drive loop itself (draining control events,
8//! publishing terminal frames, and whatever is left of the iteration before
9//! the trailing sleep), plus the loop's own actual iteration rate. It
10//! reuses that other module's [`RingStats`]/[`Distribution`]/
11//! [`duration_micros`] rather than redefining them: both profilers need the
12//! identical "last 256 samples, p50/p95/max, no allocation after
13//! construction" contract, and `gate4agent-node` already depends on
14//! `gate4agent-runtime-native` for [`gate4agent_runtime_native::NativeRuntime`]
15//! itself.
16use gate4agent_runtime_native::tick_profile::{
17    duration_micros, Distribution, RingStats, SAMPLE_WINDOW,
18};
19use std::time::{Duration, Instant};
20
21/// One snapshot of the drive loop's own phase/rate readings -- what
22/// `GET /metrics` reports alongside `NativeRuntime`'s own
23/// [`gate4agent_runtime_native::tick_profile::TickProfileSnapshot`].
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
25pub struct DriveLoopProfileSnapshot {
26    pub runtime_tick_us: Distribution,
27    pub event_drain_us: Distribution,
28    pub publish_terminal_frames_us: Distribution,
29    pub remainder_us: Distribution,
30    /// Actual iterations completed per wall-clock second, windowed over the
31    /// last [`SAMPLE_WINDOW`] seconds -- the number the ~100/sec figure
32    /// implied by the loop's `sleep(Duration::from_millis(10))` needs
33    /// checked against, since that sleep only bounds the loop from below;
34    /// it says nothing about how many iterations the phases above actually
35    /// leave room for.
36    pub iterations_per_sec: Distribution,
37}
38
39/// Owns every drive-loop phase/rate ring `drive_runtime_until_shutdown`
40/// writes into once per iteration. Lives on `NodeShared` behind a
41/// `std::sync::Mutex` that is only ever locked for a `push` and dropped
42/// before the next `.await` -- never held across one, so it does not need
43/// `tokio::sync::Mutex`.
44#[derive(Clone, Debug)]
45pub struct DriveLoopProfiler {
46    runtime_tick_us: RingStats<SAMPLE_WINDOW>,
47    event_drain_us: RingStats<SAMPLE_WINDOW>,
48    publish_terminal_frames_us: RingStats<SAMPLE_WINDOW>,
49    remainder_us: RingStats<SAMPLE_WINDOW>,
50    iterations_per_sec: RingStats<SAMPLE_WINDOW>,
51    /// Start of the one-second bucket `iterations_this_second` is counting
52    /// toward.
53    second_start: Instant,
54    iterations_this_second: u32,
55}
56
57impl Default for DriveLoopProfiler {
58    fn default() -> Self {
59        Self {
60            runtime_tick_us: RingStats::default(),
61            event_drain_us: RingStats::default(),
62            publish_terminal_frames_us: RingStats::default(),
63            remainder_us: RingStats::default(),
64            iterations_per_sec: RingStats::default(),
65            second_start: Instant::now(),
66            iterations_this_second: 0,
67        }
68    }
69}
70
71impl DriveLoopProfiler {
72    /// `runtime.tick().await` -- the whole native-runtime tick, whose own
73    /// six internal phases are separately timed by
74    /// `gate4agent_runtime_native::tick_profile`.
75    pub fn record_runtime_tick(&mut self, elapsed: Duration) {
76        self.runtime_tick_us.push(duration_micros(elapsed));
77    }
78
79    /// Draining `events.try_recv()` in a loop and republishing each control
80    /// event -- includes the per-event managed-record export lookup, but
81    /// not the detached `tokio::spawn`'d export task itself.
82    pub fn record_event_drain(&mut self, elapsed: Duration) {
83        self.event_drain_us.push(duration_micros(elapsed));
84    }
85
86    /// `shared.publish_terminal_frames()` -- snapshotting sessions, then
87    /// diffing and broadcasting any advanced terminal frame.
88    pub fn record_publish_terminal_frames(&mut self, elapsed: Duration) {
89        self.publish_terminal_frames_us.push(duration_micros(elapsed));
90    }
91
92    /// Whatever is left of the iteration after the three phases above and
93    /// before the trailing `sleep` -- today that is the shutdown-progress
94    /// check (a no-op outside shutdown) plus this profiler's own
95    /// bookkeeping.
96    pub fn record_remainder(&mut self, elapsed: Duration) {
97        self.remainder_us.push(duration_micros(elapsed));
98    }
99
100    /// Call exactly once per drive-loop iteration, regardless of what that
101    /// iteration did -- rolls the current one-second bucket into
102    /// `iterations_per_sec` the moment a full second has elapsed, then
103    /// counts this iteration toward the (possibly just-rolled) bucket.
104    /// Rolling before counting means an iteration observed after a second
105    /// boundary is credited to the new second, not folded into the one
106    /// being flushed.
107    ///
108    /// Bounded to `SAMPLE_WINDOW` catch-up rolls for the same reason as
109    /// `hatchery-tui`'s `tick_second`: a long idle gap (e.g. the process
110    /// paused under a debugger) must resync rather than spin backfilling a
111    /// zero-sample per elapsed second.
112    pub fn note_iteration(&mut self, now: Instant) {
113        let mut rolled = 0;
114        while now.saturating_duration_since(self.second_start) >= Duration::from_secs(1)
115            && rolled < SAMPLE_WINDOW
116        {
117            self.iterations_per_sec.push(self.iterations_this_second);
118            self.iterations_this_second = 0;
119            self.second_start += Duration::from_secs(1);
120            rolled += 1;
121        }
122        if rolled == SAMPLE_WINDOW {
123            self.second_start = now;
124        }
125        self.iterations_this_second = self.iterations_this_second.saturating_add(1);
126    }
127
128    pub fn snapshot(&self) -> DriveLoopProfileSnapshot {
129        DriveLoopProfileSnapshot {
130            runtime_tick_us: self.runtime_tick_us.stats(),
131            event_drain_us: self.event_drain_us.stats(),
132            publish_terminal_frames_us: self.publish_terminal_frames_us.stats(),
133            remainder_us: self.remainder_us.stats(),
134            iterations_per_sec: self.iterations_per_sec.stats(),
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn every_recorded_phase_reaches_the_snapshot() {
145        let mut profiler = DriveLoopProfiler::default();
146        profiler.record_runtime_tick(Duration::from_micros(100));
147        profiler.record_event_drain(Duration::from_micros(5));
148        profiler.record_publish_terminal_frames(Duration::from_micros(7));
149        profiler.record_remainder(Duration::from_micros(1));
150        let snapshot = profiler.snapshot();
151        assert_eq!(snapshot.runtime_tick_us.max, 100);
152        assert_eq!(snapshot.event_drain_us.max, 5);
153        assert_eq!(snapshot.publish_terminal_frames_us.max, 7);
154        assert_eq!(snapshot.remainder_us.max, 1);
155    }
156
157    #[test]
158    fn note_iteration_rolls_exactly_once_per_elapsed_second() {
159        let start = Instant::now();
160        let mut profiler = DriveLoopProfiler::default();
161        profiler.second_start = start;
162        profiler.note_iteration(start);
163        profiler.note_iteration(start + Duration::from_millis(10));
164        profiler.note_iteration(start + Duration::from_millis(1_001));
165        let snapshot = profiler.snapshot();
166        // The first two iterations land in the second that just rolled.
167        assert_eq!(snapshot.iterations_per_sec.count, 1);
168        assert_eq!(snapshot.iterations_per_sec.max, 2);
169    }
170
171    #[test]
172    fn note_iteration_caps_catch_up_rolls_after_a_long_gap() {
173        let start = Instant::now();
174        let mut profiler = DriveLoopProfiler::default();
175        profiler.second_start = start;
176        profiler.note_iteration(start + Duration::from_secs(10_000));
177        let snapshot = profiler.snapshot();
178        assert_eq!(snapshot.iterations_per_sec.count, SAMPLE_WINDOW);
179        assert_eq!(snapshot.iterations_per_sec.max, 0);
180    }
181}